Skip to main content

schema_catalog/
lib.rs

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