Skip to main content

schema_catalog/
lib.rs

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
16/// The default `$schema` URL for Lintel catalogs.
17pub const DEFAULT_SCHEMA_URL: &str =
18    "https://catalog.lintel.tools/schemas/lintel/catalog/latest.json";
19
20/// Schema catalog index that maps file patterns to JSON Schema URLs.
21///
22/// A catalog is a collection of schema entries used by editors and tools to
23/// automatically associate files with the correct schema for validation and
24/// completion. Follows the `SchemaStore` catalog format.
25///
26/// See: <https://json.schemastore.org/schema-catalog.json>
27#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
28#[schemars(title = "Schema Catalog")]
29pub struct Catalog {
30    /// The `$schema` URL for this catalog. Defaults to the Lintel catalog schema.
31    #[serde(default, rename = "$schema")]
32    pub schema: Option<String>,
33    /// The catalog format version.
34    pub version: u32,
35    /// An optional human-readable title for the catalog.
36    #[serde(default)]
37    pub title: Option<String>,
38    /// The list of schema entries in this catalog.
39    pub schemas: Vec<SchemaEntry>,
40    /// Optional grouping of related schemas for catalog consumers that
41    /// support richer organization. Consumers that don't understand
42    /// `groups` simply ignore this field.
43    #[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; // $schema, version, schemas
52        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/// A group of related schemas in the catalog.
77///
78/// Groups provide richer metadata for catalog consumers that support them.
79/// Consumers that don't understand `groups` simply ignore the field.
80#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81#[schemars(title = "Schema Group")]
82pub struct CatalogGroup {
83    /// The display name for this group.
84    pub name: String,
85    /// A short description of the schemas in this group.
86    pub description: String,
87    /// Schema names that belong to this group.
88    pub schemas: Vec<String>,
89}
90
91/// A single schema entry in the catalog.
92///
93/// Each entry maps a schema to its URL and the file patterns it applies to.
94#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
95#[schemars(title = "Schema Entry")]
96pub struct SchemaEntry {
97    /// The display name of the schema.
98    #[schemars(example = example_schema_name())]
99    pub name: String,
100    /// A short description of what the schema validates.
101    pub description: String,
102    /// The URL where the schema can be fetched.
103    #[schemars(example = example_schema_url())]
104    pub url: String,
105    /// An optional URL pointing to the upstream or canonical source of
106    /// the schema (e.g. a GitHub raw URL).
107    #[serde(default, rename = "sourceUrl", skip_serializing_if = "Option::is_none")]
108    pub source_url: Option<String>,
109    /// Glob patterns for files this schema should be applied to.
110    ///
111    /// Editors and tools use these patterns to automatically associate
112    /// matching files with this schema.
113    #[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    /// Alternate versions of this schema, keyed by version identifier.
118    /// Values are URLs to the versioned schema.
119    #[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
135/// Generate the JSON Schema for the [`Catalog`] type.
136///
137/// # Panics
138///
139/// Panics if the schema cannot be serialized to JSON (should never happen).
140pub fn schema() -> Value {
141    serde_json::to_value(schema_for!(Catalog)).expect("schema serialization cannot fail")
142}
143
144/// Parse a catalog from a JSON string.
145///
146/// # Errors
147///
148/// Returns an error if the string is not valid JSON or does not match the catalog schema.
149pub fn parse_catalog(json: &str) -> Result<Catalog, serde_json::Error> {
150    serde_json::from_str(json)
151}
152
153/// Parse a catalog from a `serde_json::Value`.
154///
155/// # Errors
156///
157/// Returns an error if the value does not match the expected catalog schema.
158pub 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        // Round-trip
230        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}