use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::super::{StringOrBool, deserialize_string_or_bool_opt};
use super::{CommitAuthorConfig, RepositoryConfig};
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct SchemastoreConfig {
pub repository: Option<RepositoryConfig>,
pub commit_author: Option<CommitAuthorConfig>,
pub versioned: Option<bool>,
#[serde(
deserialize_with = "deserialize_string_or_bool_opt",
default,
alias = "disable"
)]
pub skip: Option<StringOrBool>,
#[serde(rename = "if")]
pub if_condition: Option<String>,
pub schemas: Vec<SchemaEntry>,
pub retain_on_rollback: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct SchemaEntry {
pub name: String,
pub slug: Option<String>,
pub file_match: Vec<String>,
pub url: Option<String>,
pub schema_file: Option<String>,
#[serde(rename = "crate")]
pub crate_: Option<String>,
pub description: Option<String>,
pub versioned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
#[serde(
deserialize_with = "deserialize_string_or_bool_opt",
default,
alias = "disable"
)]
pub skip: Option<StringOrBool>,
#[serde(rename = "if")]
pub if_condition: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SchemaMode {
External,
Vendor,
}
impl SchemaEntry {
pub fn mode(&self) -> anyhow::Result<SchemaMode> {
match (self.url.is_some(), self.schema_file.is_some()) {
(true, false) => Ok(SchemaMode::External),
(false, true) => Ok(SchemaMode::Vendor),
(false, false) => anyhow::bail!(
"schemastore schema `{}`: set `url` or `schema_file`",
self.name
),
(true, true) => anyhow::bail!(
"schemastore schema `{}`: set `url` or `schema_file`, not both",
self.name
),
}
}
pub fn validate(&self) -> anyhow::Result<()> {
self.mode()?;
if self.file_match.is_empty() {
anyhow::bail!(
"schemastore schema `{}`: `file_match` must list at least one filename",
self.name
);
}
Ok(())
}
}
impl SchemastoreConfig {
pub fn resolved_repository(&self, _entry: &SchemaEntry) -> Option<&RepositoryConfig> {
self.repository.as_ref()
}
pub fn resolved_commit_author(&self, _entry: &SchemaEntry) -> Option<&CommitAuthorConfig> {
self.commit_author.as_ref()
}
pub fn resolved_versioned(&self, entry: &SchemaEntry) -> bool {
entry.versioned.or(self.versioned).unwrap_or(false)
}
pub fn resolved_skip(&self, entry: &SchemaEntry) -> bool {
let block = self
.skip
.as_ref()
.map(StringOrBool::as_bool)
.unwrap_or(false);
let per = entry
.skip
.as_ref()
.map(StringOrBool::as_bool)
.unwrap_or(false);
block || per
}
pub fn resolved_if<'a>(&'a self, entry: &'a SchemaEntry) -> Option<&'a str> {
entry
.if_condition
.as_deref()
.or(self.if_condition.as_deref())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserializes_external_and_vendor_entries() {
let yaml = r#"
repository: { owner: tj-smith47, name: schemastore }
versioned: false
schemas:
- name: Anodizer
file_match: [".anodizer.yaml", ".anodizer.yml"]
url: "https://tj-smith47.github.io/anodizer/schema.json"
description: "Anodizer Rust release-automation configuration file"
- name: cfgd-config
file_match: ["cfgd.yaml"]
schema_file: "schemas/cfgd-config.schema.json"
crate: cfgd
"#;
let cfg: SchemastoreConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.schemas.len(), 2);
assert_eq!(cfg.schemas[0].name, "Anodizer");
assert_eq!(
cfg.schemas[0].url.as_deref(),
Some("https://tj-smith47.github.io/anodizer/schema.json")
);
assert_eq!(
cfg.schemas[1].schema_file.as_deref(),
Some("schemas/cfgd-config.schema.json")
);
assert_eq!(cfg.schemas[1].crate_.as_deref(), Some("cfgd"));
}
#[test]
fn rejects_unknown_field() {
let yaml = "schemas: []\nbogus: 1\n";
assert!(serde_yaml_ng::from_str::<SchemastoreConfig>(yaml).is_err());
}
#[test]
fn per_entry_versioned_overrides_block_default() {
let cfg = SchemastoreConfig {
versioned: Some(false),
schemas: vec![
SchemaEntry {
name: "a".into(),
versioned: None,
..Default::default()
},
SchemaEntry {
name: "b".into(),
versioned: Some(true),
..Default::default()
},
],
..Default::default()
};
assert!(!cfg.resolved_versioned(&cfg.schemas[0])); assert!(cfg.resolved_versioned(&cfg.schemas[1])); }
#[test]
fn repository_and_author_fall_through_to_block() {
let repo = RepositoryConfig {
owner: Some("tj-smith47".into()),
name: Some("schemastore".into()),
..Default::default()
};
let cfg = SchemastoreConfig {
repository: Some(repo),
schemas: vec![SchemaEntry {
name: "a".into(),
..Default::default()
}],
..Default::default()
};
assert_eq!(
cfg.resolved_repository(&cfg.schemas[0])
.unwrap()
.owner
.as_deref(),
Some("tj-smith47")
);
}
#[test]
fn mode_inferred_from_field_presence() {
let ext = SchemaEntry {
name: "a".into(),
url: Some("https://x/s.json".into()),
file_match: vec!["a.yaml".into()],
..Default::default()
};
let ven = SchemaEntry {
name: "b".into(),
schema_file: Some("s.json".into()),
file_match: vec!["b.yaml".into()],
..Default::default()
};
assert_eq!(ext.mode().unwrap(), SchemaMode::External);
assert_eq!(ven.mode().unwrap(), SchemaMode::Vendor);
}
#[test]
fn validate_rejects_neither_both_and_empty_filematch() {
let neither = SchemaEntry {
name: "a".into(),
file_match: vec!["a.yaml".into()],
..Default::default()
};
assert!(
neither
.validate()
.unwrap_err()
.to_string()
.contains("url` or `schema_file")
);
let both = SchemaEntry {
name: "a".into(),
url: Some("u".into()),
schema_file: Some("s".into()),
file_match: vec!["a.yaml".into()],
..Default::default()
};
assert!(
both.validate()
.unwrap_err()
.to_string()
.contains("not both")
);
let no_fm = SchemaEntry {
name: "a".into(),
url: Some("u".into()),
file_match: vec![],
..Default::default()
};
assert!(
no_fm
.validate()
.unwrap_err()
.to_string()
.contains("file_match")
);
}
#[test]
fn disable_alias_is_accepted() {
let yaml = "schemas: []\nskip: true\n";
let cfg: SchemastoreConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(cfg.skip.is_some());
let via_alias = "schemas: []\ndisable: true\n";
let cfg2: SchemastoreConfig = serde_yaml_ng::from_str(via_alias).unwrap();
assert!(cfg2.skip.is_some());
}
}