#![allow(dead_code, deprecated)]
use indexmap::{IndexMap, IndexSet};
use schematic::schema::{IntegerKind, IntegerType, SchemaGenerator, StringType, TemplateOptions};
use schematic::*;
use starbase_sandbox::{assert_snapshot, create_empty_sandbox};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::PathBuf;
derive_enum!(
#[derive(ConfigEnum, Default)]
pub enum BasicEnum {
#[default]
Foo,
Bar,
Baz,
}
);
derive_enum!(
#[derive(ConfigEnum, Default)]
pub enum FallbackEnum {
#[default]
Foo,
Bar,
Baz,
#[variant(fallback)]
Other(String),
}
);
#[derive(Clone, Config)]
pub struct AnotherConfig {
opt: Option<String>,
enums: Option<BasicEnum>,
}
#[derive(Clone, Config)]
#[deprecated]
struct GenConfig {
boolean: bool,
string: String,
number: usize,
float32: f32,
float64: f64,
vector: Vec<String>,
map: HashMap<String, u64>,
enums: BasicEnum,
fallback_enum: FallbackEnum,
#[setting(nested)]
nested: AnotherConfig,
#[setting(flatten)]
flattened: HashMap<String, serde_json::Value>,
date: chrono::NaiveDate,
datetime: chrono::NaiveDateTime,
decimal: rust_decimal::Decimal,
time: chrono::NaiveTime,
path: PathBuf,
regex: RegexSetting,
rel_path: relative_path::RelativePathBuf,
url: Option<url::Url>,
uuid: uuid::Uuid,
version: Option<semver::Version>,
version2: VersionSetting,
version_req: semver::VersionReq,
json_value: serde_json::Value,
toml_value: Option<toml::Value>,
yaml_value: serde_norway::Value,
indexmap: IndexMap<String, String>,
indexset: Option<IndexSet<String>>,
}
#[derive(Clone, Config)]
#[config(env_prefix = "ENV_PREFIX_")]
pub struct TwoDepthConfig {
opt: Option<String>,
skipped: String,
}
#[derive(Clone, Config)]
pub struct OneDepthConfig {
#[setting(nested)]
two: TwoDepthConfig,
#[setting(skip)]
skipped: String,
}
#[derive(Clone, Config)]
struct TemplateConfig {
#[setting(env = "TEMPLATE_BOOLEAN")]
boolean: bool,
#[setting(default = "abc")]
string: String,
number: usize,
#[deprecated]
float32: f32,
#[setting(default = 1.23)]
float64: f64,
vector: Vec<String>,
map: HashMap<String, u64>,
#[deprecated = "Dont use enums!"]
enums: BasicEnum,
fallback_enum: FallbackEnum,
#[setting(nested)]
nested: AnotherConfig,
#[setting(nested)]
one: OneDepthConfig,
skipped: String,
#[setting(nested)]
expand_array: Vec<AnotherConfig>,
expand_array_primitive: Vec<usize>,
empty_array: Vec<usize>,
#[setting(nested)]
expand_object: HashMap<String, AnotherConfig>,
expand_object_primitive: HashMap<String, usize>,
empty_object: HashMap<String, usize>,
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
struct Port(u16);
impl Schematic for Port {
fn build_schema(mut schema: SchemaBuilder) -> Schema {
schema.integer(IntegerType {
kind: IntegerKind::U16,
min: Some(1024),
max: Some(49151),
..IntegerType::default()
})
}
}
#[derive(Clone, Debug, Default, PartialEq, serde::Deserialize, serde::Serialize)]
struct Ident(String);
impl Schematic for Ident {
fn build_schema(mut schema: SchemaBuilder) -> Schema {
schema.string(StringType {
max_length: Some(32),
min_length: Some(1),
pattern: Some("^[a-z][a-z0-9-]*$".into()),
..StringType::default()
})
}
}
#[derive(Clone, Config)]
#[serde(untagged)]
enum Targets {
List(Vec<String>),
Map(HashMap<String, String>),
}
#[derive(Clone, Config)]
struct DocsConfig {
#[serde(alias = "label", alias = "title")]
#[setting(default = "hello", env = "DOCS_NAME")]
name: String,
port: Port,
ident: Option<Ident>,
#[setting(nested)]
nested: Option<AnotherConfig>,
#[setting(nested)]
list: Vec<AnotherConfig>,
map: HashMap<String, BasicEnum>,
level: BasicEnum,
#[deprecated = "Use `name` instead."]
old_name: Option<String>,
pair: (String, u32),
duration: std::time::Duration,
#[setting(nested)]
targets: Targets,
nullable_items: Vec<Option<BasicEnum>>,
#[serde(default)]
boolean: bool,
#[setting(skip)]
hidden: String,
}
#[derive(Clone, Config)]
struct TaggedDocsConfig {
added: Option<String>,
old: Option<String>,
#[deprecated = "Use `added`."]
older: Option<String>,
example: Option<String>,
level: TaggedDocsEnum,
}
derive_enum!(
#[derive(ConfigEnum, Default)]
pub enum TaggedDocsEnum {
#[default]
Alpha,
Beta,
}
);
fn create_generator() -> SchemaGenerator {
let mut generator = SchemaGenerator::default();
generator.add::<GenConfig>();
generator
}
fn create_template_generator() -> SchemaGenerator {
let mut generator = SchemaGenerator::default();
generator.add::<TemplateConfig>();
generator
}
fn create_template_options() -> TemplateOptions {
TemplateOptions {
comment_fields: vec!["float32".into(), "map".into()],
expand_fields: vec![
"expand_array".into(),
"expand_array_primitive".into(),
"expand_object".into(),
"expand_object_primitive".into(),
],
hide_fields: vec!["skipped".into(), "one.two.skipped".into()],
..TemplateOptions::default()
}
}
#[cfg(feature = "renderer_json_schema")]
mod json_schema {
use super::*;
use schematic::schema::json_schema::*;
#[test]
fn defaults() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.json");
create_generator()
.generate(&file, JsonSchemaRenderer::default())
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn partials() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.json");
let mut generator = create_generator();
generator.add::<PartialGenConfig>();
generator
.generate(&file, JsonSchemaRenderer::default())
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn not_required() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.json");
create_generator()
.generate(
&file,
JsonSchemaRenderer::new(JsonSchemaOptions {
mark_struct_fields_required: false,
..JsonSchemaOptions::default()
}),
)
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn with_titles() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.json");
create_generator()
.generate(
&file,
JsonSchemaRenderer::new(JsonSchemaOptions {
set_field_name_as_title: true,
..JsonSchemaOptions::default()
}),
)
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn with_markdown_descs() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.json");
create_generator()
.generate(
&file,
JsonSchemaRenderer::new(JsonSchemaOptions {
markdown_description: true,
..JsonSchemaOptions::default()
}),
)
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
}
#[cfg(all(feature = "renderer_template", feature = "json"))]
mod template_json {
use super::*;
use schematic::schema::*;
#[test]
fn defaults() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.json");
create_template_generator()
.generate(&file, JsoncTemplateRenderer::new(create_template_options()))
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn without_comments() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.json");
create_template_generator()
.generate(&file, JsonTemplateRenderer::new(create_template_options()))
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn only_fields() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.json");
create_template_generator()
.generate(
&file,
JsoncTemplateRenderer::new({
let mut options = create_template_options();
options.only_fields.push("float32".into());
options.only_fields.push("string".into());
options
}),
)
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn custom_values() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.json");
create_template_generator()
.generate(
&file,
JsoncTemplateRenderer::new({
let mut options = create_template_options();
options.custom_values.insert(
"expandArrayPrimitive".into(),
Schema::array(ArrayType::new(Schema::integer(IntegerType::new_unsigned(
IntegerKind::Usize,
123456,
)))),
);
options.custom_values.insert(
"nested.opt".into(),
Schema::union(UnionType::new_any([
Schema::string(StringType::new("custom nested value")),
Schema::null(),
])),
);
options.custom_values.insert(
"string".into(),
Schema::string(StringType::new("custom value")),
);
options
}),
)
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
}
#[cfg(all(feature = "renderer_template", feature = "pkl"))]
mod template_pkl {
use super::*;
use schematic::schema::*;
use std::path::Path;
use std::time::Duration;
#[derive(Clone, Config)]
struct ValuesConfig {
#[setting(rename = "class")]
class_name: String,
#[setting(rename = "kebab-case")]
kebab_case: String,
output: Option<String>,
#[setting(default = "quote \" and \\(interpolation)")]
escaped: String,
#[setting(default = 1.0)]
float: f64,
pair: (String, u32),
triple: (String, u32, bool),
duration: Duration,
#[setting(nested)]
maybe: Option<AnotherConfig>,
}
fn write_template(
generator: &SchemaGenerator,
dir: &Path,
options: TemplateOptions,
) -> PathBuf {
let file = dir.join("config.pkl");
generator
.generate(&file, PklTemplateRenderer::new(options))
.unwrap();
file
}
fn load<T: Config>(file: PathBuf) -> T {
ConfigLoader::<T>::new()
.file(file)
.unwrap()
.load()
.unwrap()
.config
}
fn create_values_generator() -> SchemaGenerator {
let mut generator = SchemaGenerator::default();
generator.add::<ValuesConfig>();
generator
}
fn assert_values(config: ValuesConfig) {
assert_eq!(config.class_name, "");
assert_eq!(config.kebab_case, "");
assert_eq!(config.escaped, "quote \" and \\(interpolation)");
assert_eq!(config.float, 1.0);
assert_eq!(config.pair, ("".into(), 0));
assert_eq!(config.triple, ("".into(), 0, false));
assert_eq!(config.duration, Duration::ZERO);
assert_eq!(config.maybe.unwrap().opt, Some("".into()));
}
#[test]
fn defaults() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.pkl");
create_template_generator()
.generate(&file, PklTemplateRenderer::new(create_template_options()))
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn loads_the_template() {
let sandbox = create_empty_sandbox();
let config: TemplateConfig = load(write_template(
&create_template_generator(),
sandbox.path(),
create_template_options(),
));
assert_eq!(config.string, "abc");
assert_eq!(config.float64, 1.23);
assert_eq!(config.expand_array.len(), 1);
assert_eq!(config.expand_array_primitive, vec![0]);
assert!(config.expand_object.contains_key("example"));
assert_eq!(config.expand_object_primitive.get("example"), Some(&0));
assert_eq!(config.nested.opt, Some("".into()));
assert!(config.vector.is_empty());
}
#[test]
fn renders_values_as_pkl() {
let sandbox = create_empty_sandbox();
let file = write_template(
&create_values_generator(),
sandbox.path(),
TemplateOptions::default(),
);
let output = fs::read_to_string(&file).unwrap();
assert!(output.contains("`class` = \"\""));
assert!(output.contains("`kebab-case` = \"\""));
assert!(output.contains("escaped = \"quote \\\" and \\\\(interpolation)\""));
assert!(output.contains("float = 1.0"));
assert!(output.contains("pair = Pair(\"\", 0)"));
assert!(output.contains("triple = new Listing {"));
assert!(output.contains("duration = 0.s"));
assert!(output.contains("maybe {"));
assert!(output.contains("// The `output` setting cannot be set"));
assert_values(load(file));
}
#[test]
fn comments_out_every_line_of_a_field() {
let sandbox = create_empty_sandbox();
let file = write_template(
&create_template_generator(),
sandbox.path(),
TemplateOptions {
comment_fields: vec!["nested".into()],
..TemplateOptions::default()
},
);
let output = fs::read_to_string(&file).unwrap();
assert!(output.contains("// nested {\n// // An optional enum.\n// enums = \"foo\""));
assert!(output.contains("\n// }\n"));
let config: TemplateConfig = load(file);
assert_eq!(config.nested.opt, None);
}
#[test]
fn keeps_the_header_without_comments() {
let sandbox = create_empty_sandbox();
let file = write_template(
&create_values_generator(),
sandbox.path(),
TemplateOptions {
comments: false,
header: "amends \"ValuesConfig.pkl\"\n\n".into(),
..TemplateOptions::default()
},
);
assert!(
fs::read_to_string(file)
.unwrap()
.starts_with("amends \"ValuesConfig.pkl\"\n\n")
);
}
#[cfg(feature = "renderer_pkl_schema")]
mod amending_modules {
use super::*;
fn create_amending_options(module: &str) -> TemplateOptions {
TemplateOptions {
header: format!("amends \"{module}.pkl\"\n\n"),
..create_template_options()
}
}
fn write_modules(generator: &SchemaGenerator, dir: &Path, options: PklSchemaOptions) {
PklSchemaRenderer::new(options)
.generate_all(generator, dir)
.unwrap();
}
#[test]
fn amends_lists_and_maps() {
let sandbox = create_empty_sandbox();
let generator = create_template_generator();
write_modules(
&generator,
sandbox.path(),
PklSchemaOptions {
mark_struct_fields_required: false,
..PklSchemaOptions::default()
},
);
let file = write_template(
&generator,
sandbox.path(),
create_amending_options("TemplateConfig"),
);
assert_snapshot!(fs::read_to_string(&file).unwrap());
let config: TemplateConfig = load(file);
assert_eq!(config.string, "abc");
assert_eq!(config.expand_array.len(), 1);
assert_eq!(config.expand_array[0].opt, Some("".into()));
assert_eq!(config.expand_array_primitive, vec![0]);
assert_eq!(
config.expand_object.get("example").unwrap().opt,
Some("".into())
);
assert_eq!(config.nested.opt, Some("".into()));
assert!(config.vector.is_empty());
}
#[test]
fn amends_modules_with_required_fields() {
let sandbox = create_empty_sandbox();
let generator = create_template_generator();
write_modules(&generator, sandbox.path(), PklSchemaOptions::default());
let file = write_template(
&generator,
sandbox.path(),
TemplateOptions {
comment_fields: vec![],
hide_fields: vec![],
..create_amending_options("TemplateConfig")
},
);
let config: TemplateConfig = load(file);
assert_eq!(config.expand_array.len(), 1);
assert_eq!(config.float32, 0.0);
}
#[test]
fn amends_values() {
let sandbox = create_empty_sandbox();
let generator = create_values_generator();
write_modules(&generator, sandbox.path(), PklSchemaOptions::default());
assert_values(load(write_template(
&generator,
sandbox.path(),
TemplateOptions {
header: "amends \"ValuesConfig.pkl\"\n\n".into(),
..TemplateOptions::default()
},
)));
}
}
}
#[cfg(all(feature = "renderer_template", feature = "toml"))]
mod template_toml {
use super::*;
use schematic::schema::*;
#[test]
fn defaults() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.toml");
create_template_generator()
.generate(&file, TomlTemplateRenderer::new(create_template_options()))
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
}
#[cfg(all(feature = "renderer_template", feature = "yaml"))]
mod template_yaml {
use super::*;
use schematic::schema::*;
#[test]
fn defaults() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.yaml");
create_template_generator()
.generate(&file, YamlTemplateRenderer::new(create_template_options()))
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn issue_139() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("schema.yaml");
#[derive(Debug, Clone, Config)]
pub struct ProjectConfig {
#[setting(nested)]
pub nested: NestedConfig,
}
#[derive(Debug, Clone, Config)]
pub struct NestedConfig {
#[setting(default = true)]
pub one: bool,
}
let mut generator = SchemaGenerator::default();
generator.add::<ProjectConfig>();
generator
.generate(&file, YamlTemplateRenderer::new(create_template_options()))
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
}
#[cfg(feature = "renderer_typescript")]
mod typescript {
use super::*;
use schematic::schema::typescript::*;
fn generate(options: TypeScriptOptions) -> String {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("types.ts");
create_generator()
.generate(&file, TypeScriptRenderer::new(options))
.unwrap();
fs::read_to_string(file).unwrap()
}
#[test]
fn defaults() {
assert_snapshot!(generate(TypeScriptOptions::default()));
}
#[test]
fn doc_comment_tags() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("types.ts");
let mut generator = SchemaGenerator::default();
generator.add::<TaggedDocsConfig>();
generator
.generate(&file, TypeScriptRenderer::default())
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn partials() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("types.ts");
let mut generator = create_generator();
generator.add::<PartialGenConfig>();
generator
.generate(&file, TypeScriptRenderer::new(TypeScriptOptions::default()))
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
#[test]
fn enums() {
assert_snapshot!(generate(TypeScriptOptions {
enum_format: EnumFormat::Enum,
..TypeScriptOptions::default()
}));
}
#[test]
fn value_enums() {
assert_snapshot!(generate(TypeScriptOptions {
enum_format: EnumFormat::ValuedEnum,
..TypeScriptOptions::default()
}));
}
#[test]
fn const_enums() {
assert_snapshot!(generate(TypeScriptOptions {
const_enum: true,
enum_format: EnumFormat::Enum,
..TypeScriptOptions::default()
}));
}
#[test]
fn object_aliases() {
assert_snapshot!(generate(TypeScriptOptions {
object_format: ObjectFormat::Type,
..TypeScriptOptions::default()
}));
}
#[test]
fn props_optional() {
assert_snapshot!(generate(TypeScriptOptions {
property_format: PropertyFormat::Optional,
..TypeScriptOptions::default()
}));
}
#[test]
fn props_optional_undefined() {
assert_snapshot!(generate(TypeScriptOptions {
property_format: PropertyFormat::OptionalUndefined,
..TypeScriptOptions::default()
}));
}
#[test]
fn exclude_refs() {
assert_snapshot!(generate(TypeScriptOptions {
exclude_references: vec!["BasicEnum".into(), "AnotherType".into()],
..TypeScriptOptions::default()
}));
}
#[test]
fn external_types() {
assert_snapshot!(generate(TypeScriptOptions {
external_types: HashMap::from_iter([(
"./externals".into(),
vec!["BasicEnum".into(), "AnotherType".into()]
)]),
..TypeScriptOptions::default()
}));
}
#[test]
fn no_refs() {
assert_snapshot!(generate(TypeScriptOptions {
disable_references: true,
indent_char: " ".into(),
..TypeScriptOptions::default()
}));
}
#[derive(Clone, Schematic)]
#[serde(untagged)]
pub enum DocumentedUnion {
Value(String),
List(Vec<String>),
}
#[derive(Clone, Schematic)]
#[serde(untagged)]
#[deprecated = "Use a list."]
pub enum DeprecatedUnion {
Value(String),
List(Vec<String>),
}
#[test]
fn union_comments() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("types.ts");
let mut generator = SchemaGenerator::default();
generator.add::<DocumentedUnion>();
generator.add::<DeprecatedUnion>();
generator
.generate(&file, TypeScriptRenderer::default())
.unwrap();
assert_snapshot!(fs::read_to_string(file).unwrap());
}
}
#[cfg(feature = "renderer_api_docs")]
mod api_docs {
use super::*;
use schematic::schema::api_docs::*;
fn generate(generator: SchemaGenerator, options: ApiDocsOptions) -> String {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("docs.md");
generator
.generate(&file, ApiDocsRenderer::new(options))
.unwrap();
fs::read_to_string(file).unwrap()
}
#[test]
fn defaults() {
assert_snapshot!(generate(create_generator(), ApiDocsOptions::default()));
}
#[test]
fn partials() {
let mut generator = create_generator();
generator.add::<PartialGenConfig>();
assert_snapshot!(generate(generator, ApiDocsOptions::default()));
}
#[test]
fn all_field_shapes() {
let mut generator = SchemaGenerator::default();
generator.add::<DocsConfig>();
assert_snapshot!(generate(generator, ApiDocsOptions::default()));
}
#[test]
fn template_config() {
assert_snapshot!(generate(
create_template_generator(),
ApiDocsOptions::default()
));
}
#[test]
fn unit_enum() {
let mut generator = SchemaGenerator::default();
generator.add::<BasicEnum>();
assert_snapshot!(generate(generator, ApiDocsOptions::default()));
}
#[test]
fn fallback_enum() {
let mut generator = SchemaGenerator::default();
generator.add::<FallbackEnum>();
assert_snapshot!(generate(generator, ApiDocsOptions::default()));
}
#[test]
fn enum_table() {
let mut generator = SchemaGenerator::default();
generator.add::<FallbackEnum>();
assert_snapshot!(generate(
generator,
ApiDocsOptions {
enum_format: ApiDocsEnumFormat::Table,
..ApiDocsOptions::default()
}
));
}
#[test]
fn enum_table_leaves_unions_as_sections() {
let mut generator = SchemaGenerator::default();
generator.add::<Targets>();
let output = generate(
generator,
ApiDocsOptions {
enum_format: ApiDocsEnumFormat::Table,
..ApiDocsOptions::default()
},
);
assert!(output.contains("## Index"));
assert!(output.contains("### `List`"));
}
#[test]
fn union() {
let mut generator = SchemaGenerator::default();
generator.add::<Targets>();
assert_snapshot!(generate(generator, ApiDocsOptions::default()));
}
#[test]
fn readded_nested_type_becomes_the_page() {
let mut generator = create_generator();
generator.add::<AnotherConfig>();
let output = generate(generator, ApiDocsOptions::default());
assert!(output.starts_with("---\ntitle: AnotherConfig\n---"));
assert_snapshot!(output);
}
#[test]
fn without_required_or_aliases() {
let mut generator = SchemaGenerator::default();
generator.add::<DocsConfig>();
assert_snapshot!(generate(
generator,
ApiDocsOptions {
exclude_aliases: true,
mark_struct_fields_required: false,
..ApiDocsOptions::default()
}
));
}
#[test]
fn custom_frontmatter() {
let mut generator = SchemaGenerator::default();
generator.add::<AnotherConfig>();
let output = generate(
generator,
ApiDocsOptions {
frontmatter: BTreeMap::from_iter([
("sidebar_position".to_owned(), "2".to_owned()),
("description".to_owned(), "\"Another: config\"".to_owned()),
]),
..ApiDocsOptions::default()
},
);
assert!(output.starts_with(
"---\ntitle: AnotherConfig\ndescription: \"Another: config\"\nsidebar_position: 2\n---\n\nSome comment."
));
}
#[test]
fn frontmatter_title_overrides_the_type_name() {
let mut generator = SchemaGenerator::default();
generator.add::<AnotherConfig>();
let output = generate(
generator,
ApiDocsOptions {
frontmatter: BTreeMap::from_iter([("title".to_owned(), "Another".to_owned())]),
..ApiDocsOptions::default()
},
);
assert!(output.starts_with("---\ntitle: Another\n---\n"));
assert!(!output.contains("title: AnotherConfig"));
}
#[test]
fn without_index() {
let mut generator = SchemaGenerator::default();
generator.add::<DocsConfig>();
let output = generate(
generator,
ApiDocsOptions {
include_index: false,
..ApiDocsOptions::default()
},
);
assert!(!output.contains("## Index"));
assert!(output.contains("## Properties"));
}
#[test]
fn custom_anchors() {
let mut generator = SchemaGenerator::default();
generator.add::<DocsConfig>();
let output = generate(
generator,
ApiDocsOptions {
render_anchor: Box::new(|heading| format!("prop-{}", heading.replace('_', "-"))),
..ApiDocsOptions::default()
},
);
assert!(output.contains("| [`nullable_items`](#prop-nullable-items) |"));
assert!(!output.contains("](#nullable_items)"));
}
#[test]
fn custom_links() {
let mut generator = SchemaGenerator::default();
generator.add::<DocsConfig>();
let output = generate(
generator,
ApiDocsOptions {
render_link: Box::new(|name| {
format!("<Link to=\"/docs/config/{name}\">{name}</Link>")
}),
..ApiDocsOptions::default()
},
);
assert!(
output.contains(
"| Type | <Link to=\"/docs/config/AnotherConfig\">AnotherConfig</Link> |"
)
);
assert!(output.contains("- <Link to=\"/docs/config/AnotherConfig\">AnotherConfig</Link>"));
assert!(output.contains(
"| Type | `AnotherConfig[]` |\n| References | <Link to=\"/docs/config/AnotherConfig\">AnotherConfig</Link> |"
));
assert!(!output.contains(".md"));
}
#[test]
fn custom_tags_and_description() {
let mut generator = SchemaGenerator::default();
generator.add::<DocsConfig>();
assert_snapshot!(generate(
generator,
ApiDocsOptions {
render_tags: Box::new(|tags| {
tags.iter()
.map(|tag| match tag {
ApiDocsTag::Deprecated(Some(message)) => {
format!("<Badge>{tag}</Badge> {message}")
}
_ => format!("<Badge>{tag}</Badge>"),
})
.collect::<Vec<_>>()
.join(" ")
}),
render_description: Box::new(|description| {
format!(":::note\n{}\n:::", description.trim())
}),
..ApiDocsOptions::default()
}
));
}
#[test]
fn doc_comment_tags() {
let mut generator = SchemaGenerator::default();
generator.add::<TaggedDocsConfig>();
assert_snapshot!(generate(generator, ApiDocsOptions::default()));
}
#[test]
fn doc_comment_tags_in_enum_table() {
let mut generator = SchemaGenerator::default();
generator.add::<TaggedDocsEnum>();
let output = generate(
generator,
ApiDocsOptions {
enum_format: ApiDocsEnumFormat::Table,
..ApiDocsOptions::default()
},
);
assert!(output.contains("| `beta` | `\"beta\"` | **Since** (1.1.0) | Added later. |"));
}
#[test]
fn doc_comment_tags_left_out_of_index_page() {
let mut generator = SchemaGenerator::default();
generator.add::<TaggedDocsConfig>();
let output = ApiDocsRenderer::default()
.render_index_page(&generator.schemas)
.unwrap();
assert!(output.contains(
"| [`TaggedDocsConfig`](./TaggedDocsConfig.md) | Struct | A config with tags in its doc comments. |"
));
}
#[test]
fn labels_and_values_of_tags() {
let custom = ApiDocsTag::Custom {
name: "read_only-ish".into(),
value: Some("yes".into()),
};
assert_eq!(custom.label(), "Read only ish");
assert_eq!(custom.value(), Some("yes"));
assert_eq!(ApiDocsTag::Since("1.2.0".into()).value(), Some("1.2.0"));
assert_eq!(ApiDocsTag::Deprecated(None).value(), None);
assert_eq!(ApiDocsTag::Required.label(), "Required");
}
#[test]
fn empty_tags_and_description_are_omitted() {
let mut generator = SchemaGenerator::default();
generator.add::<AnotherConfig>();
let output = generate(
generator,
ApiDocsOptions {
render_tags: Box::new(|_| String::new()),
render_description: Box::new(|_| String::new()),
..ApiDocsOptions::default()
},
);
assert!(!output.contains("Nullable"));
assert!(!output.contains("Some comment."));
assert!(!output.contains("\n\n\n"));
assert!(output.contains("### `enums`\n\n| Attribute | Value |"));
}
#[test]
fn generate_all_writes_every_page_and_an_index() {
let sandbox = create_empty_sandbox();
let dir = sandbox.path().join("docs");
ApiDocsRenderer::default()
.generate_all(&create_generator(), &dir)
.unwrap();
let mut files = fs::read_dir(&dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().into_string().unwrap())
.collect::<Vec<_>>();
files.sort();
assert_eq!(
files,
vec![
"AnotherConfig.md",
"BasicEnum.md",
"FallbackEnum.md",
"GenConfig.md",
"index.md",
]
);
let single = generate(create_generator(), ApiDocsOptions::default());
assert_eq!(
fs::read_to_string(dir.join("GenConfig.md")).unwrap(),
single
);
assert!(
fs::read_to_string(dir.join("AnotherConfig.md"))
.unwrap()
.starts_with("---\ntitle: AnotherConfig\n---")
);
assert_snapshot!(fs::read_to_string(dir.join("index.md")).unwrap());
}
#[test]
fn generate_all_without_index_page() {
let sandbox = create_empty_sandbox();
let dir = sandbox.path().join("docs");
ApiDocsRenderer::new(ApiDocsOptions {
index_page: None,
..ApiDocsOptions::default()
})
.generate_all(&create_generator(), &dir)
.unwrap();
assert!(!dir.join("index.md").exists());
assert!(dir.join("GenConfig.md").exists());
}
#[test]
fn generate_all_with_custom_file_extension() {
let sandbox = create_empty_sandbox();
let dir = sandbox.path().join("docs");
ApiDocsRenderer::new(ApiDocsOptions {
file_extension: ".mdx".into(),
index_page: Some("index.mdx".into()),
..ApiDocsOptions::default()
})
.generate_all(&create_generator(), &dir)
.unwrap();
assert!(dir.join("GenConfig.mdx").exists());
assert!(dir.join("index.mdx").exists());
assert!(!dir.join("GenConfig.md").exists());
}
#[test]
fn generate_all_errors_without_schemas() {
let sandbox = create_empty_sandbox();
let error = ApiDocsRenderer::default()
.generate_all(&SchemaGenerator::default(), sandbox.path().join("docs"))
.unwrap_err();
assert!(error.to_string().contains("At least one type"));
}
#[test]
fn errors_without_schemas() {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("docs.md");
let error = SchemaGenerator::default()
.generate(&file, ApiDocsRenderer::default())
.unwrap_err();
assert!(error.to_string().contains("At least one type"));
}
}
#[cfg(feature = "renderer_pkl_schema")]
mod pkl_schema {
use super::*;
use schematic::schema::pkl_schema::*;
use std::collections::{BTreeSet, HashSet};
use std::num::NonZeroU8;
use std::path::Path;
use std::time::Duration;
#[derive(Clone, Config)]
pub struct BaseConfig {
#[setting(default = "shared")]
shared: String,
count: Option<u32>,
}
#[derive(Clone, Config)]
#[config(allow_unknown_fields)]
pub struct ExtendingConfig {
#[setting(flatten, nested)]
base: BaseConfig,
own: bool,
}
#[derive(Clone, Config)]
#[config(allow_unknown_fields)]
pub struct AmendingConfig {
#[setting(flatten, nested)]
base: BaseConfig,
}
#[derive(Clone, Config)]
pub struct UsesAmendingConfig {
#[setting(nested)]
amending: AmendingConfig,
}
#[derive(Clone, Config)]
pub struct TreeConfig {
name: String,
#[setting(nested)]
children: Vec<TreeConfig>,
}
#[derive(Clone, Schematic)]
#[serde(untagged)]
pub enum Expr {
Value(String),
List(Vec<Expr>),
}
#[derive(Clone, Config)]
pub struct ExternalTaggedConfig {
name: String,
}
#[derive(Clone, Config)]
enum ExternalTagged {
Foo,
Bar(bool),
Baz(usize, String),
#[setting(nested)]
Qux(ExternalTaggedConfig),
}
#[derive(Clone, Config)]
#[serde(tag = "type")]
enum InternalTagged {
Foo,
#[setting(nested)]
Qux(ExternalTaggedConfig),
}
#[derive(Clone, Config)]
#[serde(tag = "type", content = "content")]
enum AdjacentTagged {
Foo,
Bar(bool),
#[setting(nested)]
Qux(ExternalTaggedConfig),
}
#[derive(Clone, Config)]
struct TaggedConfig {
#[setting(nested)]
external: ExternalTagged,
#[setting(nested)]
internal: Option<InternalTagged>,
#[setting(nested)]
adjacent: Option<AdjacentTagged>,
}
#[derive(Clone, Config)]
struct IdentifiersConfig {
#[setting(rename = "class")]
class_name: String,
#[setting(rename = "kebab-case")]
kebab_case: Option<String>,
#[setting(rename = "$schema")]
schema: Option<String>,
default: Option<String>,
output: Option<String>,
}
#[derive(
Clone, ConfigEnum, Debug, Default, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize,
)]
#[serde(rename_all = "kebab-case")]
pub enum KeyEnum {
#[default]
Alpha,
Beta,
}
#[derive(Clone, Config)]
struct TypesConfig {
int8: i8,
int16: i16,
int32: i32,
int64: i64,
int128: i128,
isize: isize,
uint8: u8,
uint16: u16,
uint32: u32,
uint64: u64,
uint128: u128,
usize: usize,
non_zero: Option<NonZeroU8>,
#[setting(default = 1.0)]
float32: f32,
#[setting(default = 2.5)]
float64: f64,
character: char,
#[setting(default = "quote \" and backslash \\ and \\(interpolation)")]
escaped: String,
fixed_array: [String; 3],
set: HashSet<String>,
btree_set: BTreeSet<u8>,
pair: (String, u32),
triple: (String, u32, bool),
duration: Duration,
maybe_duration: Option<Duration>,
enum_map: HashMap<KeyEnum, String>,
nullable_items: Vec<Option<BasicEnum>>,
port: Port,
ident: Ident,
json: serde_json::Value,
#[setting(default = "bar")]
level: BasicEnum,
#[deprecated = "Use `level` instead."]
old_level: Option<BasicEnum>,
}
#[derive(Clone, Config)]
struct SinceConfig {
added: Option<String>,
#[deprecated = "Use `added` instead."]
removed: Option<SinceEnum>,
tagged: Option<String>,
}
derive_enum!(
#[derive(ConfigEnum, Default)]
pub enum SinceEnum {
#[default]
Alpha,
}
);
#[derive(Clone, Config)]
struct SharesAliasConfig {
level: BasicEnum,
#[setting(nested)]
nested: SharedAliasConfig,
}
#[derive(Clone, Config)]
struct SharedAliasConfig {
levels: Vec<BasicEnum>,
}
#[derive(Clone, Config)]
struct SharesClassesConfig {
#[setting(nested)]
tagged: AdjacentTagged,
#[setting(nested)]
nested: SharedClassesConfig,
}
#[derive(Clone, Config)]
struct SharedClassesConfig {
#[setting(nested)]
tagged: Option<AdjacentTagged>,
}
#[derive(Clone, Config)]
struct CollidesConfig {
#[setting(rename = "BasicEnum")]
level: BasicEnum,
}
#[derive(Clone, Schematic)]
#[serde(untagged)]
pub enum Outer {
Level(BasicEnum),
Name(String),
}
#[derive(Clone, Schematic)]
pub struct OuterConfig {
value: Outer,
}
#[derive(Clone, Schematic)]
#[serde(untagged)]
pub enum NodeRef {
Name(String),
Nodes(Vec<NodeConfig>),
}
#[derive(Clone, Schematic)]
pub struct NodeConfig {
child: Option<NodeRef>,
}
fn render(generator: SchemaGenerator, options: PklSchemaOptions) -> String {
let sandbox = create_empty_sandbox();
let file = sandbox.path().join("module.pkl");
generator
.generate(&file, PklSchemaRenderer::new(options))
.unwrap();
fs::read_to_string(file).unwrap()
}
fn render_type<T: Schematic>() -> String {
render_type_with::<T>(PklSchemaOptions::default())
}
fn render_type_with<T: Schematic>(options: PklSchemaOptions) -> String {
let mut generator = SchemaGenerator::default();
generator.add::<T>();
render(generator, options)
}
fn render_all(
generator: &SchemaGenerator,
options: PklSchemaOptions,
) -> BTreeMap<String, String> {
let sandbox = create_empty_sandbox();
PklSchemaRenderer::new(options)
.generate_all(generator, sandbox.path())
.unwrap();
fs::read_dir(sandbox.path())
.unwrap()
.map(|entry| {
let path = entry.unwrap().path();
(
path.file_name().unwrap().to_string_lossy().into_owned(),
fs::read_to_string(path).unwrap(),
)
})
.collect()
}
#[test]
fn defaults() {
assert_snapshot!(render(create_generator(), PklSchemaOptions::default()));
}
#[test]
fn partials() {
let mut generator = create_generator();
generator.add::<PartialGenConfig>();
assert_snapshot!(render(generator, PklSchemaOptions::default()));
}
#[test]
fn not_required() {
assert_snapshot!(render(
create_generator(),
PklSchemaOptions {
mark_struct_fields_required: false,
..PklSchemaOptions::default()
}
));
}
#[test]
fn all_field_shapes() {
assert_snapshot!(render_type::<DocsConfig>());
}
#[test]
fn rust_types() {
assert_snapshot!(render_type::<TypesConfig>());
}
#[test]
fn unit_enum() {
assert_snapshot!(render_type::<BasicEnum>());
}
#[test]
fn fallback_enum() {
assert_snapshot!(render_type::<FallbackEnum>());
}
#[test]
fn union() {
assert_snapshot!(render_type::<Targets>());
}
#[test]
fn tagged_enums() {
let mut generator = SchemaGenerator::default();
generator.add::<TaggedConfig>();
let modules = render_all(&generator, PklSchemaOptions::default());
assert_eq!(
modules.keys().collect::<Vec<_>>(),
vec!["ExternalTaggedConfig.pkl", "TaggedConfig.pkl"]
);
assert_snapshot!(modules.get("TaggedConfig.pkl").unwrap());
}
#[test]
fn keeps_tags_of_partial_variants() {
let mut generator = SchemaGenerator::default();
generator.add::<PartialTaggedConfig>();
generator.add::<PartialInternalTagged>();
let output = render(generator, PklSchemaOptions::default());
assert!(
output.contains(
"class PartialInternalTaggedQux {\n name: String?\n\n type: \"Qux\"\n}"
)
);
}
#[test]
fn quotes_identifiers() {
assert_snapshot!(render_type::<IdentifiersConfig>());
}
#[test]
fn indent_char() {
let output = render_type_with::<ExternalTagged>(PklSchemaOptions {
indent_char: "\t".into(),
..PklSchemaOptions::default()
});
assert!(output.contains("class ExternalTaggedBar {\n\tBar: Boolean\n}"));
}
#[test]
fn not_required_leaves_classes_as_declared() {
let output = render_type_with::<AdjacentTagged>(PklSchemaOptions {
mark_struct_fields_required: false,
..PklSchemaOptions::default()
});
assert!(
output.contains("class AdjacentTaggedBar {\n content: Boolean\n\n type: \"Bar\"\n}")
);
}
#[test]
fn extends_a_flattened_struct() {
let mut generator = SchemaGenerator::default();
generator.add::<ExtendingConfig>();
let modules = render_all(&generator, PklSchemaOptions::default());
assert_snapshot!(modules.get("ExtendingConfig.pkl").unwrap());
assert!(
modules
.get("BaseConfig.pkl")
.unwrap()
.contains("\nopen module BaseConfig\n")
);
}
#[test]
fn amends_a_flattened_struct_when_nothing_else_is_declared() {
let mut generator = SchemaGenerator::default();
generator.add::<AmendingConfig>();
let modules = render_all(&generator, PklSchemaOptions::default());
assert_snapshot!(modules.get("AmendingConfig.pkl").unwrap());
assert!(
modules
.get("BaseConfig.pkl")
.unwrap()
.contains("\nmodule BaseConfig\n")
);
}
#[test]
fn extends_instead_of_amending_when_used_as_a_type() {
let mut generator = SchemaGenerator::default();
generator.add::<UsesAmendingConfig>();
let modules = render_all(&generator, PklSchemaOptions::default());
assert!(
modules
.get("AmendingConfig.pkl")
.unwrap()
.contains("\nextends \"BaseConfig.pkl\"")
);
assert!(
modules
.get("BaseConfig.pkl")
.unwrap()
.contains("\nopen module BaseConfig\n")
);
}
#[test]
fn recursive_struct_imports_itself() {
let output = render_type::<TreeConfig>();
assert!(output.contains("import \"TreeConfig.pkl\""));
assert!(output.contains("children: Listing<TreeConfig>"));
}
#[test]
fn recursive_union_breaks_the_cycle() {
assert_snapshot!(render_type::<Expr>());
}
#[test]
fn since_tags_become_annotations() {
let mut generator = SchemaGenerator::default();
generator.add::<SinceConfig>();
assert_snapshot!(render(generator, PklSchemaOptions::default()));
}
#[test]
fn declares_an_alias_in_each_module_that_uses_it() {
let mut generator = SchemaGenerator::default();
generator.add::<SharesAliasConfig>();
let modules = render_all(&generator, PklSchemaOptions::default());
assert_eq!(
modules.keys().collect::<Vec<_>>(),
vec!["SharedAliasConfig.pkl", "SharesAliasConfig.pkl"]
);
for module in modules.values() {
assert!(module.contains("\ntypealias BasicEnum = *\"foo\" | \"bar\" | \"baz\""));
assert!(!module.contains("import \"BasicEnum.pkl\""));
}
assert!(modules["SharedAliasConfig.pkl"].contains("\nlevels: Listing<BasicEnum>\n"));
}
#[test]
fn declares_an_alias_where_the_alias_using_it_is() {
let mut generator = SchemaGenerator::default();
generator.add::<OuterConfig>();
let modules = render_all(&generator, PklSchemaOptions::default());
let module = &modules["OuterConfig.pkl"];
assert_eq!(modules.len(), 1);
assert!(module.contains("\nvalue: Outer = \"foo\"\n"));
assert!(module.contains("\ntypealias Outer = BasicEnum | String\n"));
assert!(module.contains("\ntypealias BasicEnum = "));
}
#[test]
fn keeps_a_module_for_a_shared_alias_with_classes() {
let mut generator = SchemaGenerator::default();
generator.add::<SharesClassesConfig>();
let modules = render_all(&generator, PklSchemaOptions::default());
assert!(modules["AdjacentTagged.pkl"].contains("\nclass AdjacentTaggedBar {"));
for name in ["SharesClassesConfig.pkl", "SharedClassesConfig.pkl"] {
assert!(modules[name].contains("import \"AdjacentTagged.pkl\""));
assert!(modules[name].contains(": AdjacentTagged.AdjacentTagged"));
}
}
#[test]
fn keeps_a_module_for_an_alias_named_like_a_property() {
let mut generator = SchemaGenerator::default();
generator.add::<CollidesConfig>();
let modules = render_all(&generator, PklSchemaOptions::default());
assert!(modules.contains_key("BasicEnum.pkl"));
assert!(modules["CollidesConfig.pkl"].contains("\nBasicEnum: BasicEnumModule.BasicEnum\n"));
}
#[test]
fn keeps_a_module_for_an_alias_that_refers_to_its_user() {
let mut generator = SchemaGenerator::default();
generator.add::<NodeConfig>();
let modules = render_all(&generator, PklSchemaOptions::default());
assert!(
modules["NodeRef.pkl"].contains("\ntypealias NodeRef = String | Listing<NodeConfig>\n")
);
assert!(modules["NodeConfig.pkl"].contains("\nchild: NodeRef.NodeRef?\n"));
}
#[test]
fn generate_all_writes_every_module() {
let modules = render_all(&create_generator(), PklSchemaOptions::default());
assert_eq!(
modules.keys().collect::<Vec<_>>(),
vec!["AnotherConfig.pkl", "GenConfig.pkl"]
);
assert_eq!(
modules.get("GenConfig.pkl").unwrap(),
&render(create_generator(), PklSchemaOptions::default())
);
}
#[test]
fn generate_all_errors_without_schemas() {
let sandbox = create_empty_sandbox();
let error = PklSchemaRenderer::default()
.generate_all(&SchemaGenerator::default(), sandbox.path())
.unwrap_err();
assert!(error.to_string().contains("At least one type"));
}
#[test]
fn errors_without_schemas() {
let sandbox = create_empty_sandbox();
let error = SchemaGenerator::default()
.generate(
sandbox.path().join("module.pkl"),
PklSchemaRenderer::default(),
)
.unwrap_err();
assert!(error.to_string().contains("At least one type"));
}
#[cfg(feature = "pkl")]
mod loading {
use super::*;
#[derive(Clone, Config, Debug, PartialEq)]
#[config(allow_unknown_fields)]
pub struct ServerConfig {
#[setting(default = 8080)]
port: u16,
#[setting(alias = "hostname")]
host: Option<String>,
env: Option<HashMap<String, String>>,
affected: Option<ServerAffected>,
#[setting(nested)]
plugin: Option<ServerPluginConfig>,
timeout: Option<Duration>,
pair: Option<(String, u32)>,
tags: Vec<String>,
labels: HashMap<String, String>,
level: BasicEnum,
#[setting(nested)]
nested: Option<ServerNestedConfig>,
#[setting(nested)]
targets: Option<ServerTargets>,
#[setting(flatten, nested)]
base: ServerBaseConfig,
}
#[derive(Clone, Config, Debug, PartialEq)]
pub struct ServerBaseConfig {
#[setting(default = "shared")]
shared: String,
base_level: Option<BasicEnum>,
}
#[derive(Clone, Config, Debug, PartialEq)]
pub struct ServerNestedConfig {
enabled: bool,
name: Option<String>,
}
#[derive(Clone, Config, Debug, PartialEq)]
#[serde(untagged)]
pub enum ServerTargets {
List(Vec<String>),
Map(HashMap<String, String>),
}
#[derive(Clone, Config, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct ServerAffectedConfig {
#[serde(default)]
filter: Vec<String>,
flag: Option<bool>,
pass: String,
}
#[derive(Clone, Config, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(untagged)]
pub enum ServerAffected {
Enabled(bool),
#[setting(nested)]
Object(ServerAffectedConfig),
}
#[derive(Clone, Config, Debug, PartialEq)]
#[config(allow_unknown_fields)]
pub struct ServerPluginConfig {
version: Option<String>,
#[setting(flatten)]
config: HashMap<String, serde_json::Value>,
}
#[derive(Clone, Config, Debug, PartialEq)]
#[config(allow_unknown_fields)]
pub struct ServerPluginsConfig {
proto: Option<String>,
#[setting(flatten)]
plugins: HashMap<String, serde_json::Value>,
}
fn write_modules(dir: &Path, options: PklSchemaOptions) {
let mut generator = SchemaGenerator::default();
generator.add::<ServerConfig>();
PklSchemaRenderer::new(options)
.generate_all(&generator, dir)
.unwrap();
}
fn load(dir: &Path, config: &str) -> ServerConfig {
let file = dir.join("config.pkl");
fs::write(&file, format!("amends \"ServerConfig.pkl\"\n\n{config}")).unwrap();
ConfigLoader::<ServerConfig>::new()
.file(file)
.unwrap()
.load()
.unwrap()
.config
}
#[test]
fn loads_a_config_that_amends_the_modules() {
let sandbox = create_empty_sandbox();
write_modules(
sandbox.path(),
PklSchemaOptions {
mark_struct_fields_required: false,
..PklSchemaOptions::default()
},
);
let config = load(
sandbox.path(),
r#"
timeout = 1500.ms
pair = Pair("a", 1)
tags { "a"; "b" }
labels { ["x"] = "y" }
level = "bar"
nested { enabled = true }
targets = new Listing { "one"; "two" }
shared = "custom"
base_level = "baz"
"#,
);
assert_eq!(config.port, 8080);
assert_eq!(config.host, None);
assert_eq!(config.timeout, Some(Duration::from_millis(1500)));
assert_eq!(config.pair, Some(("a".into(), 1)));
assert_eq!(config.tags, vec!["a".to_owned(), "b".to_owned()]);
assert_eq!(
config.labels,
HashMap::from_iter([("x".to_owned(), "y".to_owned())])
);
assert_eq!(config.level, BasicEnum::Bar);
assert_eq!(
config.nested,
Some(ServerNestedConfig {
enabled: true,
name: None,
})
);
assert_eq!(
config.targets,
Some(ServerTargets::List(vec!["one".into(), "two".into()]))
);
assert_eq!(config.base.shared, "custom");
assert_eq!(config.base.base_level, Some(BasicEnum::Baz));
}
#[test]
fn declares_an_alias_that_the_extended_module_declares() {
let sandbox = create_empty_sandbox();
write_layer_modules(sandbox.path());
for name in ["ServerConfig.pkl", "ServerBaseConfig.pkl"] {
assert!(
fs::read_to_string(sandbox.path().join(name))
.unwrap()
.contains("\ntypealias BasicEnum = ")
);
}
assert!(!sandbox.path().join("BasicEnum.pkl").exists());
let config = load(sandbox.path(), "level = \"baz\"\nbase_level = \"bar\"");
assert_eq!(config.level, BasicEnum::Baz);
assert_eq!(config.base.base_level, Some(BasicEnum::Bar));
}
#[test]
fn loads_the_defaults_of_required_fields() {
let sandbox = create_empty_sandbox();
write_modules(sandbox.path(), PklSchemaOptions::default());
let config = load(sandbox.path(), "");
assert_eq!(config.port, 8080);
assert_eq!(config.level, BasicEnum::Foo);
assert_eq!(config.base.shared, "shared");
assert!(config.tags.is_empty());
}
#[test]
fn rejects_a_value_of_the_wrong_type() {
let sandbox = create_empty_sandbox();
write_modules(sandbox.path(), PklSchemaOptions::default());
let file = sandbox.path().join("config.pkl");
fs::write(&file, "amends \"ServerConfig.pkl\"\n\nport = 70000\n").unwrap();
let error = ConfigLoader::<ServerConfig>::new()
.file(file)
.unwrap()
.load()
.err()
.unwrap();
assert!(format!("{error:?}").contains("UInt16"));
}
fn write_layer_modules(dir: &Path) {
write_modules(
dir,
PklSchemaOptions {
mark_struct_fields_required: false,
..PklSchemaOptions::default()
},
);
}
#[test]
fn loads_converted_lists_and_maps() {
let sandbox = create_empty_sandbox();
write_layer_modules(sandbox.path());
let config = load(
sandbox.path(),
r#"
tags = (List("a") + List("b")).toListing()
labels = Map("x", "y").toMapping()
targets = Set("one", "two").toListing()
env { ["KEY"] = "value" }
"#,
);
assert_eq!(config.tags, vec!["a".to_owned(), "b".to_owned()]);
assert_eq!(
config.labels,
HashMap::from_iter([("x".to_owned(), "y".to_owned())])
);
assert_eq!(
config.targets,
Some(ServerTargets::List(vec!["one".into(), "two".into()]))
);
assert_eq!(
config.env,
Some(HashMap::from_iter([("KEY".to_owned(), "value".to_owned())]))
);
}
#[test]
fn falls_back_to_an_alias() {
let sandbox = create_empty_sandbox();
write_layer_modules(sandbox.path());
assert!(
fs::read_to_string(sandbox.path().join("ServerConfig.pkl"))
.unwrap()
.contains("host: String? = hostname\n\n/// An alias of `host`.\nhidden hostname: String?")
);
let config = load(sandbox.path(), r#"hostname = "localhost""#);
assert_eq!(config.host, Some("localhost".into()));
}
#[test]
fn loads_a_full_type() {
let sandbox = create_empty_sandbox();
write_layer_modules(sandbox.path());
let module =
fs::read_to_string(sandbox.path().join("ServerAffectedConfig.pkl")).unwrap();
assert!(module.contains("\nfilter: Listing<String>\n"));
assert!(module.contains("\nflag: Boolean?\n"));
assert!(module.contains("\npass: String"));
let config = load(
sandbox.path(),
r#"import "ServerAffectedConfig.pkl"
affected = new ServerAffectedConfig { pass = "args" }
"#,
);
assert_eq!(
config.affected,
Some(ServerAffected::Object(ServerAffectedConfig {
filter: vec![],
flag: None,
pass: "args".into(),
}))
);
}
#[test]
fn loads_a_struct_that_collects_other_settings() {
let sandbox = create_empty_sandbox();
write_layer_modules(sandbox.path());
let module = fs::read_to_string(sandbox.path().join("ServerConfig.pkl")).unwrap();
assert!(module.contains("\nplugin: Dynamic?\n"));
let config = load(
sandbox.path(),
r#"plugin { version = "1.0"; manager = "pnpm" }"#,
);
assert_eq!(
config.plugin,
Some(ServerPluginConfig {
version: Some("1.0".into()),
config: HashMap::from_iter([(
"manager".to_owned(),
serde_json::Value::String("pnpm".into())
)]),
})
);
}
#[test]
fn extends_a_module_to_declare_other_settings() {
let sandbox = create_empty_sandbox();
let mut generator = SchemaGenerator::default();
generator.add::<ServerPluginsConfig>();
PklSchemaRenderer::new(PklSchemaOptions {
mark_struct_fields_required: false,
..PklSchemaOptions::default()
})
.generate_all(&generator, sandbox.path())
.unwrap();
let module =
fs::read_to_string(sandbox.path().join("ServerPluginsConfig.pkl")).unwrap();
assert!(module.contains("\nopen module ServerPluginsConfig\n"));
assert!(module.contains("// Any other setting is collected by `plugins`."));
let file = sandbox.path().join("config.pkl");
fs::write(
&file,
r#"extends "ServerPluginsConfig.pkl"
proto = "1.2.3"
node { version = "20" }
"#,
)
.unwrap();
let config = ConfigLoader::<ServerPluginsConfig>::new()
.file(file)
.unwrap()
.load()
.unwrap()
.config;
assert_eq!(config.proto, Some("1.2.3".into()));
assert_eq!(
config.plugins.get("node"),
Some(&serde_json::json!({ "version": "20" }))
);
}
}
}
mod name_collisions {
use super::*;
struct First;
struct Second;
macro_rules! impl_collides {
($type:ty) => {
impl Schematic for $type {
fn schema_name() -> Option<String> {
Some("Collides".into())
}
fn build_schema(mut schema: SchemaBuilder) -> Schema {
schema.string_default()
}
}
};
}
impl_collides!(First);
impl_collides!(Second);
#[test]
#[should_panic(expected = "A schema named `Collides` has already been added")]
fn reports_two_types_sharing_a_name() {
let mut generator = SchemaGenerator::default();
generator.add::<First>();
generator.add::<Second>();
}
#[test]
fn allows_adding_the_same_type_twice() {
let mut generator = SchemaGenerator::default();
generator.add::<First>();
generator.add::<First>();
assert_eq!(generator.schemas.len(), 1);
}
#[derive(Config)]
struct Recurses {
#[setting(nested)]
children: Vec<Recurses>,
}
#[test]
fn allows_a_recursive_type() {
let mut generator = SchemaGenerator::default();
generator.add::<PartialRecurses>();
assert!(generator.schemas.contains_key("PartialRecurses"));
}
}