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