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
16mod compiled;
17pub use compiled::{CompiledCatalog, SchemaMatch};
18
19pub const DEFAULT_SCHEMA_URL: &str =
21 "https://catalog.lintel.tools/schemas/lintel/catalog/latest.json";
22
23#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
31#[schemars(title = "Schema Catalog")]
32pub struct Catalog {
33 #[serde(default, rename = "$schema")]
35 pub schema: Option<String>,
36 #[serde(default = "default_version")]
38 pub version: u32,
39 #[serde(default)]
41 pub title: Option<String>,
42 pub schemas: Vec<SchemaEntry>,
44 #[serde(default)]
48 pub groups: Vec<CatalogGroup>,
49}
50
51impl Serialize for Catalog {
52 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
53 use serde::ser::SerializeMap;
54
55 let mut len = 3; if self.title.is_some() {
57 len += 1;
58 }
59 if !self.groups.is_empty() {
60 len += 1;
61 }
62
63 let mut map = serializer.serialize_map(Some(len))?;
64 map.serialize_entry(
65 "$schema",
66 self.schema.as_deref().unwrap_or(DEFAULT_SCHEMA_URL),
67 )?;
68 map.serialize_entry("version", &self.version)?;
69 if let Some(ref title) = self.title {
70 map.serialize_entry("title", title)?;
71 }
72 map.serialize_entry("schemas", &self.schemas)?;
73 if !self.groups.is_empty() {
74 map.serialize_entry("groups", &self.groups)?;
75 }
76 map.end()
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85#[schemars(title = "Schema Group")]
86pub struct CatalogGroup {
87 pub name: String,
89 pub description: String,
91 pub schemas: Vec<String>,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99#[serde(rename_all = "camelCase")]
100#[schemars(title = "Schema Entry")]
101pub struct SchemaEntry {
102 #[schemars(example = example_schema_name())]
104 pub name: String,
105 #[serde(default)]
107 pub description: String,
108 #[schemars(example = example_schema_url())]
110 pub url: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub source_url: Option<String>,
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 #[schemars(title = "File Match")]
121 #[schemars(example = example_file_match())]
122 pub file_match: Vec<String>,
123 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
126 pub versions: BTreeMap<String, String>,
127}
128
129fn default_version() -> u32 {
130 1
131}
132
133fn example_schema_name() -> String {
134 "My Config".to_owned()
135}
136
137fn example_schema_url() -> String {
138 "https://example.com/schemas/my-config.json".to_owned()
139}
140
141fn example_file_match() -> Vec<String> {
142 vec!["*.config.json".to_owned(), "**/.config.json".to_owned()]
143}
144
145#[derive(
147 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
148)]
149#[serde(rename_all = "lowercase")]
150pub enum FileFormat {
151 Json,
152 Json5,
153 Jsonc,
154 Jsonl,
155 Toml,
156 Yaml,
157 Markdown,
158}
159
160pub fn schema() -> Value {
166 serde_json::to_value(schema_for!(Catalog)).expect("schema serialization cannot fail")
167}
168
169pub fn parse_catalog(json: &str) -> Result<Catalog, serde_json::Error> {
175 serde_json::from_str(json)
176}
177
178pub fn parse_catalog_value(value: serde_json::Value) -> Result<Catalog, serde_json::Error> {
184 serde_json::from_value(value)
185}
186
187#[cfg(test)]
188mod tests {
189 use alloc::string::ToString;
190
191 use super::*;
192
193 #[test]
194 fn round_trip_catalog() {
195 let catalog = Catalog {
196 version: 1,
197 schemas: vec![SchemaEntry {
198 name: "Test Schema".into(),
199 description: "A test schema".into(),
200 url: "https://example.com/test.json".into(),
201 source_url: None,
202 file_match: vec!["*.test.json".into()],
203 versions: BTreeMap::new(),
204 }],
205 ..Catalog::default()
206 };
207 let json = serde_json::to_string_pretty(&catalog).expect("serialize");
208 let parsed: Catalog = serde_json::from_str(&json).expect("deserialize");
209 assert_eq!(parsed.version, 1);
210 assert_eq!(parsed.schemas.len(), 1);
211 assert_eq!(parsed.schemas[0].name, "Test Schema");
212 assert_eq!(parsed.schemas[0].file_match, vec!["*.test.json"]);
213 }
214
215 #[test]
216 fn parse_catalog_from_json_string() {
217 let json = r#"{"version":1,"schemas":[{"name":"test","description":"desc","url":"https://example.com/s.json","fileMatch":["*.json"]}]}"#;
218 let catalog = parse_catalog(json).expect("parse");
219 assert_eq!(catalog.schemas.len(), 1);
220 assert_eq!(catalog.schemas[0].name, "test");
221 assert_eq!(catalog.schemas[0].file_match, vec!["*.json"]);
222 }
223
224 #[test]
225 fn empty_file_match_omitted_in_serialization() {
226 let entry = SchemaEntry {
227 name: "No Match".into(),
228 description: "desc".into(),
229 url: "https://example.com/no.json".into(),
230 source_url: None,
231 file_match: vec![],
232 versions: BTreeMap::new(),
233 };
234 let json = serde_json::to_string(&entry).expect("serialize");
235 assert!(!json.contains("fileMatch"));
236 assert!(!json.contains("sourceUrl"));
237 assert!(!json.contains("versions"));
238 }
239
240 #[test]
241 fn source_url_serialized_as_camel_case() {
242 let entry = SchemaEntry {
243 name: "Test".into(),
244 description: "desc".into(),
245 url: "https://catalog.example.com/test.json".into(),
246 source_url: Some("https://upstream.example.com/test.json".into()),
247 file_match: vec![],
248 versions: BTreeMap::new(),
249 };
250 let json = serde_json::to_string(&entry).expect("serialize");
251 assert!(json.contains("\"sourceUrl\""));
252 assert!(json.contains("https://upstream.example.com/test.json"));
253
254 let parsed: SchemaEntry = serde_json::from_str(&json).expect("deserialize");
256 assert_eq!(
257 parsed.source_url.as_deref(),
258 Some("https://upstream.example.com/test.json")
259 );
260 }
261
262 #[test]
263 fn deserialize_with_versions() {
264 let json = r#"{
265 "version": 1,
266 "schemas": [{
267 "name": "test",
268 "description": "desc",
269 "url": "https://example.com/s.json",
270 "versions": {"draft-07": "https://example.com/draft07.json"}
271 }]
272 }"#;
273 let catalog = parse_catalog(json).expect("parse");
274 assert_eq!(
275 catalog.schemas[0].versions.get("draft-07"),
276 Some(&"https://example.com/draft07.json".to_string())
277 );
278 }
279
280 #[test]
281 fn schema_has_camel_case_properties() {
282 let schema = schema();
283 let schema_str = serde_json::to_string(&schema).expect("serialize");
284 assert!(
285 schema_str.contains("fileMatch"),
286 "schema should contain fileMatch"
287 );
288 assert!(
289 schema_str.contains("sourceUrl"),
290 "schema should contain sourceUrl"
291 );
292 }
293}