use std::collections::BTreeMap;
use serde::Serialize;
use crate::value::QuillValue;
use super::{CardSchema, FieldSchema, QuillConfig, UiContainerSchema, UiFieldSchema};
#[derive(Debug, Clone, Serialize)]
struct PublicSchema {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
example: Option<String>,
fields: BTreeMap<String, PublicField>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
cards: BTreeMap<String, PublicCard>,
}
#[derive(Debug, Clone, Serialize)]
struct PublicField {
#[serde(rename = "type")]
field_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "is_false")]
required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<QuillValue>,
#[serde(skip_serializing_if = "Option::is_none")]
examples: Option<Vec<QuillValue>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "enum")]
enum_values: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
properties: Option<BTreeMap<String, PublicField>>,
#[serde(skip_serializing_if = "Option::is_none")]
items: Option<Box<PublicField>>,
#[serde(skip_serializing_if = "Option::is_none")]
ui: Option<PublicUiField>,
}
#[derive(Debug, Clone, Serialize)]
struct PublicCard {
#[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
fields: BTreeMap<String, PublicField>,
#[serde(skip_serializing_if = "Option::is_none")]
ui: Option<PublicUiContainer>,
}
#[derive(Debug, Clone, Serialize)]
struct PublicUiField {
#[serde(skip_serializing_if = "Option::is_none")]
group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
order: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
compact: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
multiline: Option<bool>,
}
#[derive(Debug, Clone, Serialize)]
struct PublicUiContainer {
#[serde(skip_serializing_if = "Option::is_none")]
hide_body: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
default_title: Option<String>,
}
fn is_false(value: &bool) -> bool {
!*value
}
fn map_ui_field(ui: &UiFieldSchema) -> PublicUiField {
PublicUiField {
group: ui.group.clone(),
order: ui.order,
compact: ui.compact,
multiline: ui.multiline,
}
}
fn map_ui_container(ui: &UiContainerSchema) -> PublicUiContainer {
PublicUiContainer {
hide_body: ui.hide_body,
default_title: ui.default_title.clone(),
}
}
fn map_field(field: &FieldSchema) -> PublicField {
PublicField {
field_type: field.r#type.as_yaml_str().to_string(),
title: field.title.clone(),
description: field.description.clone(),
required: field.required,
default: field.default.clone(),
examples: field.examples.as_ref().and_then(|values| {
values.as_array().map(|arr| {
arr.iter()
.map(|value| QuillValue::from_json(value.clone()))
.collect()
})
}),
enum_values: field.enum_values.clone(),
properties: field.properties.as_ref().map(|properties| {
properties
.iter()
.map(|(name, schema)| (name.clone(), map_field(schema)))
.collect()
}),
items: field.items.as_ref().map(|items| Box::new(map_field(items))),
ui: field.ui.as_ref().map(map_ui_field),
}
}
fn map_card(card: &CardSchema) -> PublicCard {
PublicCard {
title: card.title.clone(),
description: card.description.clone(),
fields: card
.fields
.iter()
.map(|(name, field)| (name.clone(), map_field(field)))
.collect(),
ui: card.ui.as_ref().map(map_ui_container),
}
}
impl QuillConfig {
pub fn public_schema_yaml(&self) -> Result<String, serde_yaml::Error> {
let schema = PublicSchema {
name: self.name.clone(),
description: self.main().description.clone(),
example: self.example_markdown.clone(),
fields: self
.main()
.fields
.iter()
.map(|(name, field)| (name.clone(), map_field(field)))
.collect(),
cards: self
.card_definitions()
.iter()
.map(|card| (card.name.clone(), map_card(card)))
.collect(),
};
serde_yaml::to_string(&schema)
}
}
#[cfg(test)]
mod tests {
use crate::quill::QuillConfig;
fn config_from_yaml(yaml: &str) -> QuillConfig {
QuillConfig::from_yaml(yaml).expect("valid quill yaml")
}
#[test]
fn emits_minimal_public_schema() {
let config = config_from_yaml(
r#"
Quill:
name: test_schema
version: "1.0"
backend: typst
description: Test schema
main:
fields:
memo_for:
type: string
description: Memo recipient
"#,
);
let yaml = config.public_schema_yaml().unwrap();
assert!(yaml.contains("name: test_schema"));
assert!(yaml.contains("fields:"));
assert!(yaml.contains("memo_for:"));
assert!(yaml.contains("type: string"));
}
#[test]
fn omits_cards_when_absent() {
let config = config_from_yaml(
r#"
Quill:
name: no_cards
version: "1.0"
backend: typst
description: No cards
main:
fields:
title:
type: string
"#,
);
let yaml = config.public_schema_yaml().unwrap();
assert!(!yaml.contains("cards:"));
}
#[test]
fn includes_cards_ui_and_enum() {
fn has_internal_key(value: &serde_yaml::Value) -> bool {
match value {
serde_yaml::Value::Mapping(map) => map.iter().any(|(k, v)| {
let is_internal = k.as_str().is_some_and(|s| s.starts_with("x-"));
is_internal || has_internal_key(v)
}),
serde_yaml::Value::Sequence(seq) => seq.iter().any(has_internal_key),
_ => false,
}
}
let config = config_from_yaml(
r#"
Quill:
name: card_schema
version: "1.0"
backend: typst
description: Card schema
main:
fields:
status:
type: string
enum: [draft, final]
ui:
group: Meta
cards:
indorsement:
title: Indorsement
fields:
signature_block:
type: string
"#,
);
let yaml = config.public_schema_yaml().unwrap();
assert!(yaml.contains("enum:"));
assert!(yaml.contains("ui:"));
assert!(yaml.contains("cards:"));
assert!(yaml.contains("indorsement:"));
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).expect("valid yaml");
assert!(!has_internal_key(&parsed));
assert!(!yaml.contains("CARDS:"));
}
#[test]
fn includes_example_when_present() {
let mut config = config_from_yaml(
r#"
Quill:
name: with_example
version: "1.0"
backend: typst
description: Has example
main:
fields:
body:
type: markdown
"#,
);
config.example_markdown = Some("---\nQUILL: test\n---\n\n# Heading".to_string());
let yaml = config.public_schema_yaml().unwrap();
assert!(yaml.contains("example:"));
assert!(yaml.contains("QUILL: test"));
}
#[test]
fn round_trips_with_serde_yaml_value() {
let config = config_from_yaml(
r#"
Quill:
name: round_trip
version: "1.0"
backend: typst
description: Round trip
main:
fields:
recipients:
type: array
items:
type: object
properties:
name:
type: string
required: true
"#,
);
let yaml = config.public_schema_yaml().unwrap();
let parsed: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(
parsed.get("name").and_then(|v| v.as_str()),
Some("round_trip")
);
assert!(parsed.get("fields").is_some());
}
}