Skip to main content

anodizer_core/config/publishers/
schemastore.rs

1//! `schemastore:` publisher config — registers a tool's JSON Schema(s) on
2//! SchemaStore. Field presence selects the mode: `url` ⇒ external (catalog
3//! entry only), `schema_file` ⇒ vendor (file copied into the SchemaStore repo).
4
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use super::super::{StringOrBool, deserialize_string_or_bool_opt};
9use super::{CommitAuthorConfig, RepositoryConfig};
10
11/// Top-level `schemastore:` block. Shared fields here are defaults for every
12/// entry in `schemas`; a per-entry field overrides them (cascade).
13#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
14#[serde(default, deny_unknown_fields)]
15pub struct SchemastoreConfig {
16    /// Fork of `SchemaStore/schemastore` to push branches to and open the PR from.
17    pub repository: Option<RepositoryConfig>,
18    /// Commit author for the SchemaStore commit (defaults to git config).
19    pub commit_author: Option<CommitAuthorConfig>,
20    /// Default for `SchemaEntry::versioned`.
21    pub versioned: Option<bool>,
22    /// Skip the whole publisher. Alias: `disable`.
23    #[serde(
24        deserialize_with = "deserialize_string_or_bool_opt",
25        default,
26        alias = "disable"
27    )]
28    pub skip: Option<StringOrBool>,
29    /// Tera condition; when it renders falsy the publisher is skipped.
30    #[serde(rename = "if")]
31    pub if_condition: Option<String>,
32    /// The schema entries to register/refresh.
33    pub schemas: Vec<SchemaEntry>,
34}
35
36/// One schema registration. `url` XOR `schema_file` selects the mode.
37#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
38#[serde(default, deny_unknown_fields)]
39pub struct SchemaEntry {
40    /// Catalog display name (may be Title Case, e.g. `Anodizer`).
41    pub name: String,
42    /// Vendor filename / url basename. Defaults to `name` slugified. Vendor-only.
43    pub slug: Option<String>,
44    /// Well-known config filenames this schema validates (folder globs need `**/`).
45    pub file_match: Vec<String>,
46    /// EXTERNAL mode: the URL you host the schema at.
47    pub url: Option<String>,
48    /// VENDOR mode: repo-root-relative path to the generated schema file.
49    pub schema_file: Option<String>,
50    /// Crate whose version a vendored/versioned schema tracks (per-crate workspaces).
51    #[serde(rename = "crate")]
52    pub crate_: Option<String>,
53    /// Catalog description (required at publish time; derived if omitted).
54    pub description: Option<String>,
55    /// Emit a version-suffixed vendored file + `versions` map. Vendor-only.
56    pub versioned: Option<bool>,
57    /// Whether a failure here fails the release. Collapsed across `schemas`.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub required: Option<bool>,
60    /// Per-entry skip. Alias: `disable`.
61    #[serde(
62        deserialize_with = "deserialize_string_or_bool_opt",
63        default,
64        alias = "disable"
65    )]
66    pub skip: Option<StringOrBool>,
67    /// Per-entry Tera condition.
68    #[serde(rename = "if")]
69    pub if_condition: Option<String>,
70}
71
72/// Hosting mode, inferred from which source field is set.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum SchemaMode {
75    External,
76    Vendor,
77}
78
79impl SchemaEntry {
80    /// Infer the mode from field presence. Error if neither/both source fields set.
81    pub fn mode(&self) -> anyhow::Result<SchemaMode> {
82        match (self.url.is_some(), self.schema_file.is_some()) {
83            (true, false) => Ok(SchemaMode::External),
84            (false, true) => Ok(SchemaMode::Vendor),
85            (false, false) => anyhow::bail!(
86                "schemastore schema `{}`: set `url` or `schema_file`",
87                self.name
88            ),
89            (true, true) => anyhow::bail!(
90                "schemastore schema `{}`: set `url` or `schema_file`, not both",
91                self.name
92            ),
93        }
94    }
95
96    /// Config-shape validation (mode + file_match). Content rules that need the
97    /// resolved description/dialect are checked later in `manifest`.
98    pub fn validate(&self) -> anyhow::Result<()> {
99        self.mode()?;
100        if self.file_match.is_empty() {
101            anyhow::bail!(
102                "schemastore schema `{}`: `file_match` must list at least one filename",
103                self.name
104            );
105        }
106        Ok(())
107    }
108}
109
110impl SchemastoreConfig {
111    /// Effective `repository` for an entry (block-level; one fork per PR).
112    pub fn resolved_repository(&self, _entry: &SchemaEntry) -> Option<&RepositoryConfig> {
113        self.repository.as_ref()
114    }
115
116    /// Effective `commit_author` (block-level).
117    pub fn resolved_commit_author(&self, _entry: &SchemaEntry) -> Option<&CommitAuthorConfig> {
118        self.commit_author.as_ref()
119    }
120
121    /// Effective `versioned`: per-entry wins, else block default, else false.
122    pub fn resolved_versioned(&self, entry: &SchemaEntry) -> bool {
123        entry.versioned.or(self.versioned).unwrap_or(false)
124    }
125
126    /// Effective `skip`: true if either the entry or the block sets it truthy.
127    pub fn resolved_skip(&self, entry: &SchemaEntry) -> bool {
128        let block = self
129            .skip
130            .as_ref()
131            .map(StringOrBool::as_bool)
132            .unwrap_or(false);
133        let per = entry
134            .skip
135            .as_ref()
136            .map(StringOrBool::as_bool)
137            .unwrap_or(false);
138        block || per
139    }
140
141    /// Effective `if` condition: per-entry wins, else block.
142    pub fn resolved_if<'a>(&'a self, entry: &'a SchemaEntry) -> Option<&'a str> {
143        entry
144            .if_condition
145            .as_deref()
146            .or(self.if_condition.as_deref())
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn deserializes_external_and_vendor_entries() {
156        let yaml = r#"
157repository: { owner: tj-smith47, name: schemastore }
158versioned: false
159schemas:
160  - name: Anodizer
161    file_match: [".anodizer.yaml", ".anodizer.yml"]
162    url: "https://tj-smith47.github.io/anodizer/schema.json"
163    description: "Anodizer Rust release-automation configuration file"
164  - name: cfgd-config
165    file_match: ["cfgd.yaml"]
166    schema_file: "schemas/cfgd-config.schema.json"
167    crate: cfgd
168"#;
169        let cfg: SchemastoreConfig = serde_yaml_ng::from_str(yaml).unwrap();
170        assert_eq!(cfg.schemas.len(), 2);
171        assert_eq!(cfg.schemas[0].name, "Anodizer");
172        assert_eq!(
173            cfg.schemas[0].url.as_deref(),
174            Some("https://tj-smith47.github.io/anodizer/schema.json")
175        );
176        assert_eq!(
177            cfg.schemas[1].schema_file.as_deref(),
178            Some("schemas/cfgd-config.schema.json")
179        );
180        assert_eq!(cfg.schemas[1].crate_.as_deref(), Some("cfgd"));
181    }
182
183    #[test]
184    fn rejects_unknown_field() {
185        let yaml = "schemas: []\nbogus: 1\n";
186        assert!(serde_yaml_ng::from_str::<SchemastoreConfig>(yaml).is_err());
187    }
188
189    #[test]
190    fn per_entry_versioned_overrides_block_default() {
191        let cfg = SchemastoreConfig {
192            versioned: Some(false),
193            schemas: vec![
194                SchemaEntry {
195                    name: "a".into(),
196                    versioned: None,
197                    ..Default::default()
198                },
199                SchemaEntry {
200                    name: "b".into(),
201                    versioned: Some(true),
202                    ..Default::default()
203                },
204            ],
205            ..Default::default()
206        };
207        assert!(!cfg.resolved_versioned(&cfg.schemas[0])); // inherits block
208        assert!(cfg.resolved_versioned(&cfg.schemas[1])); // overrides
209    }
210
211    #[test]
212    fn repository_and_author_fall_through_to_block() {
213        let repo = RepositoryConfig {
214            owner: Some("tj-smith47".into()),
215            name: Some("schemastore".into()),
216            ..Default::default()
217        };
218        let cfg = SchemastoreConfig {
219            repository: Some(repo),
220            schemas: vec![SchemaEntry {
221                name: "a".into(),
222                ..Default::default()
223            }],
224            ..Default::default()
225        };
226        assert_eq!(
227            cfg.resolved_repository(&cfg.schemas[0])
228                .unwrap()
229                .owner
230                .as_deref(),
231            Some("tj-smith47")
232        );
233    }
234
235    #[test]
236    fn mode_inferred_from_field_presence() {
237        let ext = SchemaEntry {
238            name: "a".into(),
239            url: Some("https://x/s.json".into()),
240            file_match: vec!["a.yaml".into()],
241            ..Default::default()
242        };
243        let ven = SchemaEntry {
244            name: "b".into(),
245            schema_file: Some("s.json".into()),
246            file_match: vec!["b.yaml".into()],
247            ..Default::default()
248        };
249        assert_eq!(ext.mode().unwrap(), SchemaMode::External);
250        assert_eq!(ven.mode().unwrap(), SchemaMode::Vendor);
251    }
252
253    #[test]
254    fn validate_rejects_neither_both_and_empty_filematch() {
255        let neither = SchemaEntry {
256            name: "a".into(),
257            file_match: vec!["a.yaml".into()],
258            ..Default::default()
259        };
260        assert!(
261            neither
262                .validate()
263                .unwrap_err()
264                .to_string()
265                .contains("url` or `schema_file")
266        );
267        let both = SchemaEntry {
268            name: "a".into(),
269            url: Some("u".into()),
270            schema_file: Some("s".into()),
271            file_match: vec!["a.yaml".into()],
272            ..Default::default()
273        };
274        assert!(
275            both.validate()
276                .unwrap_err()
277                .to_string()
278                .contains("not both")
279        );
280        let no_fm = SchemaEntry {
281            name: "a".into(),
282            url: Some("u".into()),
283            file_match: vec![],
284            ..Default::default()
285        };
286        assert!(
287            no_fm
288                .validate()
289                .unwrap_err()
290                .to_string()
291                .contains("file_match")
292        );
293    }
294
295    #[test]
296    fn disable_alias_is_accepted() {
297        let yaml = "schemas: []\nskip: true\n";
298        let cfg: SchemastoreConfig = serde_yaml_ng::from_str(yaml).unwrap();
299        assert!(cfg.skip.is_some());
300
301        let via_alias = "schemas: []\ndisable: true\n";
302        let cfg2: SchemastoreConfig = serde_yaml_ng::from_str(via_alias).unwrap();
303        assert!(cfg2.skip.is_some());
304    }
305}