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/// Schema catalog index that maps file patterns to JSON Schema URLs.
17///
18/// A catalog is a collection of schema entries used by editors and tools to
19/// automatically associate files with the correct schema for validation and
20/// completion. Follows the `SchemaStore` catalog format.
21///
22/// See: <https://json.schemastore.org/schema-catalog.json>
23#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
24#[schemars(title = "catalog.json")]
25pub struct Catalog {
26    /// The catalog format version. Currently always `1`.
27    pub version: u32,
28    /// An optional human-readable title for the catalog.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub title: Option<String>,
31    /// The list of schema entries in this catalog.
32    pub schemas: Vec<SchemaEntry>,
33    /// Optional grouping of related schemas for catalog consumers that
34    /// support richer organization. Consumers that don't understand
35    /// `groups` simply ignore this field.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub groups: Vec<CatalogGroup>,
38}
39
40/// A group of related schemas in the catalog.
41///
42/// Groups provide richer metadata for catalog consumers that support them.
43/// Consumers that don't understand `groups` simply ignore the field.
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45#[schemars(title = "Schema Group")]
46pub struct CatalogGroup {
47    /// The display name for this group.
48    pub name: String,
49    /// A short description of the schemas in this group.
50    pub description: String,
51    /// Schema names that belong to this group.
52    pub schemas: Vec<String>,
53}
54
55/// A single schema entry in the catalog.
56///
57/// Each entry maps a schema to its URL and the file patterns it applies to.
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59#[schemars(title = "Schema Entry")]
60pub struct SchemaEntry {
61    /// The display name of the schema.
62    #[schemars(example = example_schema_name())]
63    pub name: String,
64    /// A short description of what the schema validates.
65    pub description: String,
66    /// The URL where the schema can be fetched.
67    #[schemars(example = example_schema_url())]
68    pub url: String,
69    /// An optional URL pointing to the upstream or canonical source of
70    /// the schema (e.g. a GitHub raw URL).
71    #[serde(default, rename = "sourceUrl", skip_serializing_if = "Option::is_none")]
72    pub source_url: Option<String>,
73    /// Glob patterns for files this schema should be applied to.
74    ///
75    /// Editors and tools use these patterns to automatically associate
76    /// matching files with this schema.
77    #[serde(default, rename = "fileMatch", skip_serializing_if = "Vec::is_empty")]
78    #[schemars(title = "File Match")]
79    #[schemars(example = example_file_match())]
80    pub file_match: Vec<String>,
81    /// Alternate versions of this schema, keyed by version identifier.
82    /// Values are URLs to the versioned schema.
83    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
84    pub versions: BTreeMap<String, String>,
85}
86
87fn example_schema_name() -> String {
88    "My Config".to_owned()
89}
90
91fn example_schema_url() -> String {
92    "https://example.com/schemas/my-config.json".to_owned()
93}
94
95fn example_file_match() -> Vec<String> {
96    vec!["*.config.json".to_owned(), "**/.config.json".to_owned()]
97}
98
99/// Generate the JSON Schema for the [`Catalog`] type.
100///
101/// # Panics
102///
103/// Panics if the schema cannot be serialized to JSON (should never happen).
104pub fn schema() -> Value {
105    serde_json::to_value(schema_for!(Catalog)).expect("schema serialization cannot fail")
106}
107
108/// Parse a catalog from a JSON string.
109///
110/// # Errors
111///
112/// Returns an error if the string is not valid JSON or does not match the catalog schema.
113pub fn parse_catalog(json: &str) -> Result<Catalog, serde_json::Error> {
114    serde_json::from_str(json)
115}
116
117/// Parse a catalog from a `serde_json::Value`.
118///
119/// # Errors
120///
121/// Returns an error if the value does not match the expected catalog schema.
122pub fn parse_catalog_value(value: serde_json::Value) -> Result<Catalog, serde_json::Error> {
123    serde_json::from_value(value)
124}
125
126#[cfg(test)]
127mod tests {
128    use alloc::string::ToString;
129
130    use super::*;
131
132    #[test]
133    fn round_trip_catalog() {
134        let catalog = Catalog {
135            version: 1,
136            title: None,
137            schemas: vec![SchemaEntry {
138                name: "Test Schema".into(),
139                description: "A test schema".into(),
140                url: "https://example.com/test.json".into(),
141                source_url: None,
142                file_match: vec!["*.test.json".into()],
143                versions: BTreeMap::new(),
144            }],
145            groups: vec![],
146        };
147        let json = serde_json::to_string_pretty(&catalog).expect("serialize");
148        let parsed: Catalog = serde_json::from_str(&json).expect("deserialize");
149        assert_eq!(parsed.version, 1);
150        assert_eq!(parsed.schemas.len(), 1);
151        assert_eq!(parsed.schemas[0].name, "Test Schema");
152        assert_eq!(parsed.schemas[0].file_match, vec!["*.test.json"]);
153    }
154
155    #[test]
156    fn parse_catalog_from_json_string() {
157        let json = r#"{"version":1,"schemas":[{"name":"test","description":"desc","url":"https://example.com/s.json","fileMatch":["*.json"]}]}"#;
158        let catalog = parse_catalog(json).expect("parse");
159        assert_eq!(catalog.schemas.len(), 1);
160        assert_eq!(catalog.schemas[0].name, "test");
161        assert_eq!(catalog.schemas[0].file_match, vec!["*.json"]);
162    }
163
164    #[test]
165    fn empty_file_match_omitted_in_serialization() {
166        let entry = SchemaEntry {
167            name: "No Match".into(),
168            description: "desc".into(),
169            url: "https://example.com/no.json".into(),
170            source_url: None,
171            file_match: vec![],
172            versions: BTreeMap::new(),
173        };
174        let json = serde_json::to_string(&entry).expect("serialize");
175        assert!(!json.contains("fileMatch"));
176        assert!(!json.contains("sourceUrl"));
177        assert!(!json.contains("versions"));
178    }
179
180    #[test]
181    fn source_url_serialized_as_camel_case() {
182        let entry = SchemaEntry {
183            name: "Test".into(),
184            description: "desc".into(),
185            url: "https://catalog.example.com/test.json".into(),
186            source_url: Some("https://upstream.example.com/test.json".into()),
187            file_match: vec![],
188            versions: BTreeMap::new(),
189        };
190        let json = serde_json::to_string(&entry).expect("serialize");
191        assert!(json.contains("\"sourceUrl\""));
192        assert!(json.contains("https://upstream.example.com/test.json"));
193
194        // Round-trip
195        let parsed: SchemaEntry = serde_json::from_str(&json).expect("deserialize");
196        assert_eq!(
197            parsed.source_url.as_deref(),
198            Some("https://upstream.example.com/test.json")
199        );
200    }
201
202    #[test]
203    fn deserialize_with_versions() {
204        let json = r#"{
205            "version": 1,
206            "schemas": [{
207                "name": "test",
208                "description": "desc",
209                "url": "https://example.com/s.json",
210                "versions": {"draft-07": "https://example.com/draft07.json"}
211            }]
212        }"#;
213        let catalog = parse_catalog(json).expect("parse");
214        assert_eq!(
215            catalog.schemas[0].versions.get("draft-07"),
216            Some(&"https://example.com/draft07.json".to_string())
217        );
218    }
219
220    #[test]
221    fn schema_has_camel_case_properties() {
222        let schema = schema();
223        let schema_str = serde_json::to_string(&schema).expect("serialize");
224        assert!(
225            schema_str.contains("fileMatch"),
226            "schema should contain fileMatch"
227        );
228        assert!(
229            schema_str.contains("sourceUrl"),
230            "schema should contain sourceUrl"
231        );
232    }
233}