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
16mod compiled;
17pub use compiled::{CompiledCatalog, SchemaMatch};
18
19/// The default `$schema` URL for Lintel catalogs.
20pub const DEFAULT_SCHEMA_URL: &str =
21    "https://catalog.lintel.tools/schemas/lintel/catalog/latest.json";
22
23/// Schema catalog index that maps file patterns to JSON Schema URLs.
24///
25/// A catalog is a collection of schema entries used by editors and tools to
26/// automatically associate files with the correct schema for validation and
27/// completion. Follows the `SchemaStore` catalog format.
28///
29/// See: <https://json.schemastore.org/schema-catalog.json>
30#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
31#[schemars(title = "Schema Catalog")]
32pub struct Catalog {
33    /// The `$schema` URL for this catalog. Defaults to the Lintel catalog schema.
34    #[serde(default, rename = "$schema")]
35    pub schema: Option<String>,
36    /// The catalog format version.
37    #[serde(default = "default_version")]
38    pub version: u32,
39    /// An optional human-readable title for the catalog.
40    #[serde(default)]
41    pub title: Option<String>,
42    /// The list of schema entries in this catalog.
43    pub schemas: Vec<SchemaEntry>,
44    /// Optional grouping of related schemas for catalog consumers that
45    /// support richer organization. Consumers that don't understand
46    /// `groups` simply ignore this field.
47    #[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; // $schema, version, schemas
56        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/// A group of related schemas in the catalog.
81///
82/// Groups provide richer metadata for catalog consumers that support them.
83/// Consumers that don't understand `groups` simply ignore the field.
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85#[schemars(title = "Schema Group")]
86pub struct CatalogGroup {
87    /// The display name for this group.
88    pub name: String,
89    /// A short description of the schemas in this group.
90    pub description: String,
91    /// Schema names that belong to this group.
92    pub schemas: Vec<String>,
93}
94
95/// A single schema entry in the catalog.
96///
97/// Each entry maps a schema to its URL and the file patterns it applies to.
98#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
99#[serde(rename_all = "camelCase")]
100#[schemars(title = "Schema Entry")]
101pub struct SchemaEntry {
102    /// The display name of the schema.
103    #[schemars(example = example_schema_name())]
104    pub name: String,
105    /// A short description of what the schema validates.
106    #[serde(default)]
107    pub description: String,
108    /// The URL where the schema can be fetched.
109    #[schemars(example = example_schema_url())]
110    pub url: String,
111    /// An optional URL pointing to the upstream or canonical source of
112    /// the schema (e.g. a GitHub raw URL).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub source_url: Option<String>,
115    /// Glob patterns for files this schema should be applied to.
116    ///
117    /// Editors and tools use these patterns to automatically associate
118    /// matching files with this schema.
119    #[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    /// Alternate versions of this schema, keyed by version identifier.
124    /// Values are URLs to the versioned schema.
125    #[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/// Supported file formats for parsing and validation.
146#[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
160/// Generate the JSON Schema for the [`Catalog`] type.
161///
162/// # Panics
163///
164/// Panics if the schema cannot be serialized to JSON (should never happen).
165pub fn schema() -> Value {
166    serde_json::to_value(schema_for!(Catalog)).expect("schema serialization cannot fail")
167}
168
169/// Parse a catalog from a JSON string.
170///
171/// # Errors
172///
173/// Returns an error if the string is not valid JSON or does not match the catalog schema.
174pub fn parse_catalog(json: &str) -> Result<Catalog, serde_json::Error> {
175    serde_json::from_str(json)
176}
177
178/// Parse a catalog from a `serde_json::Value`.
179///
180/// # Errors
181///
182/// Returns an error if the value does not match the expected catalog schema.
183pub 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        // Round-trip
255        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}