use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::GenerateArgs;
pub const BUILTIN_PYTHON: &str = "python";
pub const CONFIG_NAMES: &[&str] = &[
"crozier.yml",
"crozier.yaml",
".crozier.yml",
".crozier.yaml",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum GeneratorType {
Python,
}
impl GeneratorType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
GeneratorType::Python => "python",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum ExtraFields {
#[default]
Allow,
Ignore,
Forbid,
}
impl ExtraFields {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ExtraFields::Allow => "allow",
ExtraFields::Ignore => "ignore",
ExtraFields::Forbid => "forbid",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub enum EnumType {
#[default]
PythonEnums,
Literals,
}
impl EnumType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
EnumType::PythonEnums => "python-enums",
EnumType::Literals => "literals",
}
}
}
pub const DEFAULT_MAX_RETRIES: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, JsonSchema, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Layout {
#[default]
Packaged,
Flat,
}
impl Layout {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Layout::Packaged => "packaged",
Layout::Flat => "flat",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ReferenceSettings {
pub command: Option<String>,
}
impl ReferenceSettings {
fn merge(base: Option<Self>, over: Option<Self>) -> Option<Self> {
match (base, over) {
(Some(base), Some(over)) => Some(Self {
command: over.command.or(base.command),
}),
(base, over) => over.or(base),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct GeneratorSettings {
#[serde(rename = "type")]
pub generator_type: Option<GeneratorType>,
pub spec: Option<PathBuf>,
pub output: Option<PathBuf>,
pub package_name: Option<String>,
pub project_name: Option<String>,
pub client_class_name: Option<String>,
pub audiences: Option<Vec<String>>,
pub audience_strict: Option<bool>,
pub fern_strict: Option<bool>,
pub extra_fields: Option<ExtraFields>,
pub enum_type: Option<EnumType>,
pub default_max_retries: Option<u32>,
pub layout: Option<Layout>,
pub reference: Option<ReferenceSettings>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct FileConfig {
pub spec: Option<PathBuf>,
pub output: Option<PathBuf>,
pub package_name: Option<String>,
pub project_name: Option<String>,
pub client_class_name: Option<String>,
pub audiences: Option<Vec<String>>,
pub audience_strict: Option<bool>,
pub fern_strict: Option<bool>,
pub layout: Option<Layout>,
pub reference: Option<ReferenceSettings>,
#[serde(default)]
#[schemars(with = "std::collections::BTreeMap<String, GeneratorSettings>")]
pub generators: IndexMap<String, GeneratorSettings>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CliOverrides {
pub spec: Option<PathBuf>,
pub output: Option<PathBuf>,
pub package_name: Option<String>,
pub project_name: Option<String>,
pub client_class_name: Option<String>,
pub audiences: Option<Vec<String>>,
pub audience_strict: Option<bool>,
pub fern_strict: Option<bool>,
pub extra_fields: Option<ExtraFields>,
pub enum_type: Option<EnumType>,
pub default_max_retries: Option<u32>,
pub layout: Option<Layout>,
}
impl CliOverrides {
#[must_use]
pub fn is_empty(&self) -> bool {
self.spec.is_none()
&& self.output.is_none()
&& self.package_name.is_none()
&& self.project_name.is_none()
&& self.client_class_name.is_none()
&& self.audiences.is_none()
&& self.audience_strict.is_none()
&& self.fern_strict.is_none()
&& self.extra_fields.is_none()
&& self.enum_type.is_none()
&& self.default_max_retries.is_none()
&& self.layout.is_none()
}
}
#[derive(Debug, Default)]
pub struct LoadedConfig {
pub config: FileConfig,
pub files: Vec<PathBuf>,
}
pub fn parse(text: &str) -> std::result::Result<FileConfig, String> {
let config: FileConfig = crate::yaml::from_str(text).map_err(|e| e.to_string())?;
validate(&config)?;
Ok(config)
}
fn validate(config: &FileConfig) -> std::result::Result<(), String> {
for name in config.generators.keys() {
if name.trim().is_empty() {
return Err("a generator name must be non-empty".to_string());
}
}
Ok(())
}
#[must_use]
pub fn merge(base: FileConfig, over: FileConfig) -> FileConfig {
let mut generators = base.generators;
for (name, o) in over.generators {
let entry = generators.entry(name).or_default();
*entry = merge_generator(std::mem::take(entry), o);
}
FileConfig {
spec: over.spec.or(base.spec),
output: over.output.or(base.output),
package_name: over.package_name.or(base.package_name),
project_name: over.project_name.or(base.project_name),
client_class_name: over.client_class_name.or(base.client_class_name),
audiences: over.audiences.or(base.audiences),
audience_strict: over.audience_strict.or(base.audience_strict),
fern_strict: over.fern_strict.or(base.fern_strict),
layout: over.layout.or(base.layout),
reference: ReferenceSettings::merge(base.reference, over.reference),
generators,
}
}
fn merge_generator(base: GeneratorSettings, over: GeneratorSettings) -> GeneratorSettings {
GeneratorSettings {
generator_type: over.generator_type.or(base.generator_type),
spec: over.spec.or(base.spec),
output: over.output.or(base.output),
package_name: over.package_name.or(base.package_name),
project_name: over.project_name.or(base.project_name),
client_class_name: over.client_class_name.or(base.client_class_name),
audiences: over.audiences.or(base.audiences),
audience_strict: over.audience_strict.or(base.audience_strict),
fern_strict: over.fern_strict.or(base.fern_strict),
extra_fields: over.extra_fields.or(base.extra_fields),
enum_type: over.enum_type.or(base.enum_type),
default_max_retries: over.default_max_retries.or(base.default_max_retries),
layout: over.layout.or(base.layout),
reference: ReferenceSettings::merge(base.reference, over.reference),
}
}
pub fn env_overrides(get: impl Fn(&str) -> Option<String>) -> Result<GeneratorSettings> {
let read = |name: &str| get(name).filter(|v| !v.is_empty());
let flag = |name: &str| -> Result<Option<bool>> {
match read(name) {
None => Ok(None),
Some(v) => parse_bool(&v)
.map(Some)
.ok_or_else(|| Error::InvalidEnvOverride {
message: format!("`{name}` must be `true` or `false`, got `{v}`"),
}),
}
};
let audience_strict = flag("CROZIER_AUDIENCE_STRICT")?;
let fern_strict = flag("CROZIER_FERN_STRICT")?;
let audiences = read("CROZIER_AUDIENCES").map(|v| split_list(&v));
let extra_fields = match read("CROZIER_EXTRA_FIELDS") {
None => None,
Some(v) => Some(
parse_extra_fields(&v).ok_or_else(|| Error::InvalidEnvOverride {
message: format!(
"`CROZIER_EXTRA_FIELDS` must be `allow`, `ignore`, or `forbid`, got `{v}`"
),
})?,
),
};
let enum_type = match read("CROZIER_ENUM_TYPE") {
None => None,
Some(v) => Some(
parse_enum_type(&v).ok_or_else(|| Error::InvalidEnvOverride {
message: format!(
"`CROZIER_ENUM_TYPE` must be `python-enums` or `literals`, got `{v}`"
),
})?,
),
};
let default_max_retries = match read("CROZIER_DEFAULT_MAX_RETRIES") {
None => None,
Some(v) => Some(v.parse::<u32>().map_err(|_| Error::InvalidEnvOverride {
message: format!(
"`CROZIER_DEFAULT_MAX_RETRIES` must be a non-negative integer, got `{v}`"
),
})?),
};
let layout = match read("CROZIER_LAYOUT") {
None => None,
Some(v) => Some(parse_layout(&v).ok_or_else(|| Error::InvalidEnvOverride {
message: format!("`CROZIER_LAYOUT` must be `packaged` or `flat`, got `{v}`"),
})?),
};
Ok(GeneratorSettings {
generator_type: None,
spec: read("CROZIER_SPEC").map(PathBuf::from),
output: read("CROZIER_OUTPUT").map(PathBuf::from),
package_name: read("CROZIER_PACKAGE_NAME"),
project_name: read("CROZIER_PROJECT_NAME"),
client_class_name: read("CROZIER_CLIENT_CLASS_NAME"),
audiences,
audience_strict,
fern_strict,
extra_fields,
enum_type,
default_max_retries,
layout,
reference: None,
})
}
fn parse_layout(v: &str) -> Option<Layout> {
match v.to_ascii_lowercase().as_str() {
"packaged" => Some(Layout::Packaged),
"flat" => Some(Layout::Flat),
_ => None,
}
}
fn parse_enum_type(v: &str) -> Option<EnumType> {
match v.to_ascii_lowercase().as_str() {
"python-enums" => Some(EnumType::PythonEnums),
"literals" => Some(EnumType::Literals),
_ => None,
}
}
fn parse_extra_fields(v: &str) -> Option<ExtraFields> {
match v.to_ascii_lowercase().as_str() {
"allow" => Some(ExtraFields::Allow),
"ignore" => Some(ExtraFields::Ignore),
"forbid" => Some(ExtraFields::Forbid),
_ => None,
}
}
fn parse_bool(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"true" | "1" => Some(true),
"false" | "0" => Some(false),
_ => None,
}
}
fn split_list(v: &str) -> Vec<String> {
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
pub fn run_set(config: &FileConfig, selected: Option<&str>) -> Result<Vec<String>> {
match selected {
Some(name) => {
if config.generators.contains_key(name) || name == BUILTIN_PYTHON {
Ok(vec![name.to_string()])
} else {
Err(Error::UnknownGenerator {
name: name.to_string(),
available: available_names(config),
})
}
}
None => {
if config.generators.is_empty() {
Ok(vec![BUILTIN_PYTHON.to_string()])
} else {
Ok(config.generators.keys().cloned().collect())
}
}
}
}
fn available_names(config: &FileConfig) -> String {
let mut names: Vec<&str> = config.generators.keys().map(String::as_str).collect();
if !config.generators.contains_key(BUILTIN_PYTHON) {
names.push(BUILTIN_PYTHON);
}
names.join(", ")
}
pub fn resolve(
name: &str,
config: &FileConfig,
env: &GeneratorSettings,
cli: &CliOverrides,
) -> Result<GenerateArgs> {
let per = config.generators.get(name);
let spec = cli
.spec
.clone()
.or_else(|| env.spec.clone())
.or_else(|| per.and_then(|p| p.spec.clone()))
.or_else(|| config.spec.clone())
.ok_or_else(|| Error::MissingSpec {
name: name.to_string(),
})?;
let output = cli
.output
.clone()
.or_else(|| env.output.clone())
.or_else(|| per.and_then(|p| p.output.clone()))
.or_else(|| config.output.clone())
.ok_or_else(|| Error::MissingOutput {
name: name.to_string(),
})?;
let package_name = cli
.package_name
.clone()
.or_else(|| env.package_name.clone())
.or_else(|| per.and_then(|p| p.package_name.clone()))
.or_else(|| config.package_name.clone());
let project_name = cli
.project_name
.clone()
.or_else(|| env.project_name.clone())
.or_else(|| per.and_then(|p| p.project_name.clone()))
.or_else(|| config.project_name.clone());
let client_class_name = cli
.client_class_name
.clone()
.or_else(|| env.client_class_name.clone())
.or_else(|| per.and_then(|p| p.client_class_name.clone()))
.or_else(|| config.client_class_name.clone());
let audiences = cli
.audiences
.clone()
.or_else(|| env.audiences.clone())
.or_else(|| per.and_then(|p| p.audiences.clone()))
.or_else(|| config.audiences.clone())
.unwrap_or_default();
let audience_strict = cli
.audience_strict
.or(env.audience_strict)
.or(per.and_then(|p| p.audience_strict))
.or(config.audience_strict)
.unwrap_or(false);
let fern_strict = cli
.fern_strict
.or(env.fern_strict)
.or(per.and_then(|p| p.fern_strict))
.or(config.fern_strict)
.unwrap_or(false);
let extra_fields = cli
.extra_fields
.or(env.extra_fields)
.or(per.and_then(|p| p.extra_fields))
.unwrap_or_default();
let enum_type = cli
.enum_type
.or(env.enum_type)
.or(per.and_then(|p| p.enum_type))
.unwrap_or_default();
let default_max_retries = cli
.default_max_retries
.or(env.default_max_retries)
.or(per.and_then(|p| p.default_max_retries))
.unwrap_or(DEFAULT_MAX_RETRIES);
let layout = cli
.layout
.or(env.layout)
.or(per.and_then(|p| p.layout))
.or(config.layout)
.unwrap_or_default();
Ok(GenerateArgs {
spec,
output,
package_name,
project_name,
client_class_name,
audiences,
audience_strict,
fern_strict,
extra_fields,
enum_type,
default_max_retries,
layout,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Cli,
Env,
Generator,
Shared,
Default,
}
impl Source {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Source::Cli => "cli",
Source::Env => "env",
Source::Generator => "generator",
Source::Shared => "shared",
Source::Default => "default",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldSource {
pub field: &'static str,
pub value: Option<String>,
pub source: Source,
}
fn pick(layers: &[(Source, Option<String>)]) -> (Option<String>, Source) {
for (src, val) in layers {
if let Some(v) = val {
return (Some(v.clone()), *src);
}
}
(None, Source::Default)
}
fn field(
name: &'static str,
cli: Option<String>,
env: Option<String>,
gen_val: Option<String>,
shared: Option<String>,
) -> FieldSource {
let (value, source) = pick(&[
(Source::Cli, cli),
(Source::Env, env),
(Source::Generator, gen_val),
(Source::Shared, shared),
]);
FieldSource {
field: name,
value,
source,
}
}
pub fn explain(
name: &str,
config: &FileConfig,
env: &GeneratorSettings,
cli: &CliOverrides,
) -> Vec<FieldSource> {
let per = config.generators.get(name);
let path = |p: &PathBuf| p.display().to_string();
let list = |v: &[String]| {
if v.is_empty() {
"[]".to_string()
} else {
v.join(", ")
}
};
let type_field = {
let (value, source) = pick(&[(
Source::Generator,
per.and_then(|p| p.generator_type)
.map(|t| t.as_str().to_string()),
)]);
match value {
Some(v) => FieldSource {
field: "type",
value: Some(v),
source,
},
None => FieldSource {
field: "type",
value: Some(BUILTIN_PYTHON.to_string()),
source: Source::Default,
},
}
};
vec![
type_field,
field(
"spec",
cli.spec.as_ref().map(path),
env.spec.as_ref().map(path),
per.and_then(|p| p.spec.as_ref()).map(path),
config.spec.as_ref().map(path),
),
field(
"output",
cli.output.as_ref().map(path),
env.output.as_ref().map(path),
per.and_then(|p| p.output.as_ref()).map(path),
config.output.as_ref().map(path),
),
field(
"package-name",
cli.package_name.clone(),
env.package_name.clone(),
per.and_then(|p| p.package_name.clone()),
config.package_name.clone(),
),
field(
"project-name",
cli.project_name.clone(),
env.project_name.clone(),
per.and_then(|p| p.project_name.clone()),
config.project_name.clone(),
),
field(
"client-class-name",
cli.client_class_name.clone(),
env.client_class_name.clone(),
per.and_then(|p| p.client_class_name.clone()),
config.client_class_name.clone(),
),
field(
"audiences",
cli.audiences.as_deref().map(list),
env.audiences.as_deref().map(list),
per.and_then(|p| p.audiences.as_deref()).map(list),
config.audiences.as_deref().map(list),
),
field(
"audience-strict",
cli.audience_strict.map(|b| b.to_string()),
env.audience_strict.map(|b| b.to_string()),
per.and_then(|p| p.audience_strict).map(|b| b.to_string()),
config.audience_strict.map(|b| b.to_string()),
),
{
let mut fern_strict = field(
"fern-strict",
cli.fern_strict.map(|b| b.to_string()),
env.fern_strict.map(|b| b.to_string()),
per.and_then(|p| p.fern_strict).map(|b| b.to_string()),
config.fern_strict.map(|b| b.to_string()),
);
fern_strict.value.get_or_insert_with(|| false.to_string());
fern_strict
},
field(
"extra-fields",
cli.extra_fields.map(|e| e.as_str().to_string()),
env.extra_fields.map(|e| e.as_str().to_string()),
per.and_then(|p| p.extra_fields)
.map(|e| e.as_str().to_string()),
None,
),
{
let show = |e: EnumType| e.as_str().to_string();
let mut enum_type = field(
"enum-type",
cli.enum_type.map(show),
env.enum_type.map(show),
per.and_then(|p| p.enum_type).map(show),
None,
);
enum_type
.value
.get_or_insert_with(|| show(EnumType::default()));
enum_type
},
{
let show = |n: u32| n.to_string();
let mut retries = field(
"default-max-retries",
cli.default_max_retries.map(show),
env.default_max_retries.map(show),
per.and_then(|p| p.default_max_retries).map(show),
None,
);
retries
.value
.get_or_insert_with(|| show(DEFAULT_MAX_RETRIES));
retries
},
layout_field(cli, env, per, config),
field(
"reference.command",
None,
None,
per.and_then(|p| p.reference.as_ref())
.and_then(|r| r.command.clone()),
config.reference.as_ref().and_then(|r| r.command.clone()),
),
]
}
#[must_use]
pub fn resolve_reference_command(
name: &str,
config: &FileConfig,
flag: Option<&str>,
) -> Option<(String, Source)> {
let per = config
.generators
.get(name)
.and_then(|p| p.reference.as_ref())
.and_then(|r| r.command.clone());
let shared = config.reference.as_ref().and_then(|r| r.command.clone());
match pick(&[
(Source::Cli, flag.map(str::to_string)),
(Source::Generator, per),
(Source::Shared, shared),
]) {
(Some(command), source) => Some((command, source)),
(None, _) => None,
}
}
fn layout_field(
cli: &CliOverrides,
env: &GeneratorSettings,
per: Option<&GeneratorSettings>,
config: &FileConfig,
) -> FieldSource {
let show = |l: Layout| l.as_str().to_string();
let mut resolved = field(
"layout",
cli.layout.map(show),
env.layout.map(show),
per.and_then(|p| p.layout).map(show),
config.layout.map(show),
);
if resolved.value.is_none() {
resolved.value = Some(show(Layout::default()));
}
resolved
}
#[must_use]
pub fn discover(dir: &Path) -> Option<PathBuf> {
CONFIG_NAMES
.iter()
.map(|name| dir.join(name))
.find(|path| path.is_file())
}
pub fn load(explicit: &[PathBuf], no_config: bool, dir: &Path) -> Result<LoadedConfig> {
let mut loaded = LoadedConfig::default();
if no_config {
return Ok(loaded);
}
if explicit.is_empty() {
if let Some(path) = discover(dir) {
loaded.config = read_config(&path)?;
loaded.files.push(path);
}
} else {
for path in explicit {
loaded.config = merge(loaded.config, read_config(path)?);
loaded.files.push(path.clone());
}
}
Ok(loaded)
}
fn read_config(path: &Path) -> Result<FileConfig> {
let text = std::fs::read_to_string(path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Error::ConfigNotFound {
path: path.to_path_buf(),
}
} else {
Error::ReadConfig {
path: path.to_path_buf(),
source,
}
}
})?;
parse(&text).map_err(|message| Error::ParseConfig {
path: path.to_path_buf(),
message,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parsed(text: &str) -> FileConfig {
parse(text).expect("config should parse")
}
#[test]
fn empty_config_is_all_defaults() {
assert_eq!(parsed(""), FileConfig::default());
}
#[test]
fn full_config_round_trips() {
let c = parsed(
"spec: ./api.yml\naudiences: [public]\naudience-strict: true\n\ngenerators:\n python:\n output: ./sdks/py\n package-name: my_api\n project-name: my-api\n admin:\n type: python\n spec: ./admin.yml\n output: ./sdks/admin\n",
);
assert_eq!(c.spec.as_deref(), Some(Path::new("./api.yml")));
assert_eq!(c.audiences.as_deref().unwrap(), ["public"]);
assert_eq!(c.audience_strict, Some(true));
assert_eq!(c.generators.len(), 2);
let names: Vec<&str> = c.generators.keys().map(String::as_str).collect();
assert_eq!(names, ["python", "admin"]);
let py = &c.generators["python"];
assert_eq!(py.output.as_deref(), Some(Path::new("./sdks/py")));
assert_eq!(py.package_name.as_deref(), Some("my_api"));
assert_eq!(
c.generators["admin"].generator_type,
Some(GeneratorType::Python)
);
}
#[test]
fn unknown_top_level_field_is_rejected() {
let err = parse("spac: ./x.yml").unwrap_err();
assert!(err.contains("spac"), "{err}");
}
#[test]
fn unknown_generator_field_is_rejected() {
let err = parse("generators:\n python:\n packge-name: x").unwrap_err();
assert!(err.contains("packge-name"), "{err}");
}
#[test]
fn unknown_generator_type_is_rejected() {
let err = parse("generators:\n ts:\n type: typescript").unwrap_err();
assert!(
err.contains("typescript") || err.contains("python"),
"{err}"
);
}
#[test]
fn merge_prefers_over_per_field_and_falls_through() {
let merged = merge(
parsed("spec: ./base.yml\npackage-name: base"),
parsed("package-name: over"),
);
assert_eq!(merged.spec.as_deref(), Some(Path::new("./base.yml")));
assert_eq!(merged.package_name.as_deref(), Some("over"));
}
#[test]
fn merge_generators_by_name_then_field() {
let base = parsed("generators:\n python:\n output: ./a\n package-name: keep");
let over = parsed("generators:\n python:\n output: ./b\n extra:\n spec: ./e.yml");
let merged = merge(base, over);
let py = &merged.generators["python"];
assert_eq!(py.output.as_deref(), Some(Path::new("./b")));
assert_eq!(py.package_name.as_deref(), Some("keep"));
assert!(merged.generators.contains_key("extra"));
}
fn env_get<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_string())
}
}
#[test]
fn env_overrides_map_every_field() {
let e = env_overrides(env_get(&[
("CROZIER_SPEC", "./e.yml"),
("CROZIER_OUTPUT", "./out"),
("CROZIER_PACKAGE_NAME", "pkg"),
("CROZIER_PROJECT_NAME", "proj"),
("CROZIER_AUDIENCES", "public, internal"),
("CROZIER_AUDIENCE_STRICT", "true"),
]))
.unwrap();
assert_eq!(e.spec.as_deref(), Some(Path::new("./e.yml")));
assert_eq!(e.output.as_deref(), Some(Path::new("./out")));
assert_eq!(e.package_name.as_deref(), Some("pkg"));
assert_eq!(e.project_name.as_deref(), Some("proj"));
assert_eq!(e.audiences.as_deref().unwrap(), ["public", "internal"]);
assert_eq!(e.audience_strict, Some(true));
}
#[test]
fn env_empty_values_are_unset() {
let e = env_overrides(env_get(&[
("CROZIER_SPEC", ""),
("CROZIER_PACKAGE_NAME", ""),
]))
.unwrap();
assert_eq!(e, GeneratorSettings::default());
}
#[test]
fn env_bad_boolean_is_rejected() {
let err = env_overrides(env_get(&[("CROZIER_AUDIENCE_STRICT", "maybe")])).unwrap_err();
assert!(err.to_string().contains("CROZIER_AUDIENCE_STRICT"), "{err}");
}
#[test]
fn env_extra_fields_parses_case_insensitively() {
for (raw, expected) in [
("allow", ExtraFields::Allow),
("IGNORE", ExtraFields::Ignore),
("Forbid", ExtraFields::Forbid),
] {
let e = env_overrides(env_get(&[("CROZIER_EXTRA_FIELDS", raw)])).unwrap();
assert_eq!(e.extra_fields, Some(expected), "{raw}");
}
}
#[test]
fn env_bad_extra_fields_is_rejected() {
let err = env_overrides(env_get(&[("CROZIER_EXTRA_FIELDS", "keep")])).unwrap_err();
assert!(err.to_string().contains("CROZIER_EXTRA_FIELDS"), "{err}");
}
#[test]
fn run_set_named_config_generator() {
let c = parsed("generators:\n admin:\n spec: ./a.yml");
assert_eq!(run_set(&c, Some("admin")).unwrap(), ["admin"]);
}
#[test]
fn run_set_builtin_python_needs_no_config() {
assert_eq!(
run_set(&FileConfig::default(), Some("python")).unwrap(),
["python"]
);
}
#[test]
fn run_set_unknown_generator_lists_available() {
let c = parsed("generators:\n admin:\n spec: ./a.yml");
let err = run_set(&c, Some("nope")).unwrap_err().to_string();
assert!(err.contains("nope"), "{err}");
assert!(err.contains("admin") && err.contains("python"), "{err}");
}
#[test]
fn run_set_none_runs_all_configured_in_order() {
let c = parsed("generators:\n a:\n spec: ./a.yml\n b:\n spec: ./b.yml");
assert_eq!(run_set(&c, None).unwrap(), ["a", "b"]);
}
#[test]
fn run_set_none_falls_back_to_builtin_python() {
assert_eq!(run_set(&FileConfig::default(), None).unwrap(), ["python"]);
}
#[test]
fn resolve_layers_cli_over_env_over_per_gen_over_top() {
let config = parsed(
"spec: ./top.yml\npackage-name: top\n\ngenerators:\n python:\n spec: ./gen.yml\n output: ./gen-out\n package-name: gen",
);
let env = env_overrides(env_get(&[("CROZIER_PACKAGE_NAME", "env")])).unwrap();
let cli = CliOverrides {
output: Some(PathBuf::from("./cli-out")),
..CliOverrides::default()
};
let args = resolve("python", &config, &env, &cli).unwrap();
assert_eq!(args.spec, PathBuf::from("./gen.yml"));
assert_eq!(args.output, PathBuf::from("./cli-out"));
assert_eq!(args.package_name.as_deref(), Some("env"));
}
#[test]
fn extra_fields_is_generator_specific() {
let config = parsed(
"generators:\n python:\n spec: ./a.yml\n output: ./o\n extra-fields: ignore",
);
let args = resolve(
"python",
&config,
&GeneratorSettings::default(),
&CliOverrides::default(),
)
.unwrap();
assert_eq!(args.extra_fields, ExtraFields::Ignore);
let env = env_overrides(env_get(&[("CROZIER_EXTRA_FIELDS", "forbid")])).unwrap();
let args = resolve("python", &config, &env, &CliOverrides::default()).unwrap();
assert_eq!(args.extra_fields, ExtraFields::Forbid);
let cli = CliOverrides {
extra_fields: Some(ExtraFields::Allow),
..CliOverrides::default()
};
let args = resolve("python", &config, &env, &cli).unwrap();
assert_eq!(args.extra_fields, ExtraFields::Allow);
let bare = parsed("generators:\n python:\n spec: ./a.yml\n output: ./o");
let args = resolve(
"python",
&bare,
&GeneratorSettings::default(),
&CliOverrides::default(),
)
.unwrap();
assert_eq!(args.extra_fields, ExtraFields::Allow);
}
#[test]
fn extra_fields_is_rejected_at_the_shared_top_level() {
let err = parse("extra-fields: ignore").unwrap_err();
assert!(err.contains("extra-fields"), "{err}");
}
#[test]
fn explain_attributes_extra_fields_and_omits_shared_layer() {
let config = parsed(
"generators:\n python:\n spec: ./a.yml\n output: ./o\n extra-fields: forbid",
);
let env = env_overrides(env_get(&[("CROZIER_EXTRA_FIELDS", "ignore")])).unwrap();
let report = explain("python", &config, &env, &CliOverrides::default());
let ef = report
.iter()
.find(|f| f.field == "extra-fields")
.expect("extra-fields present");
assert_eq!(ef.value.as_deref(), Some("ignore"));
assert_eq!(ef.source, Source::Env);
}
#[test]
fn env_enum_type_parses_case_insensitively_and_rejects_others() {
for (raw, expected) in [
("python-enums", EnumType::PythonEnums),
("Literals", EnumType::Literals),
("PYTHON-ENUMS", EnumType::PythonEnums),
] {
let e = env_overrides(env_get(&[("CROZIER_ENUM_TYPE", raw)])).unwrap();
assert_eq!(e.enum_type, Some(expected), "{raw}");
}
let err = env_overrides(env_get(&[("CROZIER_ENUM_TYPE", "python_enums")])).unwrap_err();
assert!(err.to_string().contains("CROZIER_ENUM_TYPE"), "{err}");
}
#[test]
fn enum_type_is_generator_specific_and_defaults_to_python_enums() {
let config = parsed(
"generators:\n python:\n spec: ./a.yml\n output: ./o\n enum-type: literals",
);
let none = GeneratorSettings::default();
let resolved = |env: &GeneratorSettings, cli: &CliOverrides| {
resolve("python", &config, env, cli).unwrap().enum_type
};
assert_eq!(
resolved(&none, &CliOverrides::default()),
EnumType::Literals
);
let env = env_overrides(env_get(&[("CROZIER_ENUM_TYPE", "python-enums")])).unwrap();
assert_eq!(
resolved(&env, &CliOverrides::default()),
EnumType::PythonEnums
);
let cli = CliOverrides {
enum_type: Some(EnumType::Literals),
..CliOverrides::default()
};
assert!(!cli.is_empty());
assert_eq!(resolved(&env, &cli), EnumType::Literals);
let bare = parsed("generators:\n python:\n spec: ./a.yml\n output: ./o");
let args = resolve("python", &bare, &none, &CliOverrides::default()).unwrap();
assert_eq!(args.enum_type, EnumType::PythonEnums);
let err = parse("enum-type: literals").unwrap_err();
assert!(err.contains("enum-type"), "{err}");
}
#[test]
fn explain_attributes_enum_type_and_shows_the_default() {
let config = parsed(
"generators:\n python:\n spec: ./a.yml\n output: ./o\n enum-type: literals",
);
let row = |config: &FileConfig, env: &GeneratorSettings| {
explain("python", config, env, &CliOverrides::default())
.into_iter()
.find(|f| f.field == "enum-type")
.expect("enum-type present")
};
let none = GeneratorSettings::default();
let from_generator = row(&config, &none);
assert_eq!(from_generator.value.as_deref(), Some("literals"));
assert_eq!(from_generator.source, Source::Generator);
let env = env_overrides(env_get(&[("CROZIER_ENUM_TYPE", "python-enums")])).unwrap();
let from_env = row(&config, &env);
assert_eq!(from_env.value.as_deref(), Some("python-enums"));
assert_eq!(from_env.source, Source::Env);
let default = row(&FileConfig::default(), &none);
assert_eq!(default.value.as_deref(), Some("python-enums"));
assert_eq!(default.source, Source::Default);
}
#[test]
fn env_default_max_retries_parses_integers_and_rejects_others() {
for (raw, expected) in [("0", 0), ("5", 5)] {
let e = env_overrides(env_get(&[("CROZIER_DEFAULT_MAX_RETRIES", raw)])).unwrap();
assert_eq!(e.default_max_retries, Some(expected), "{raw}");
}
for raw in ["-1", "two", "1.5"] {
let err = env_overrides(env_get(&[("CROZIER_DEFAULT_MAX_RETRIES", raw)])).unwrap_err();
assert!(
err.to_string()
.contains("`CROZIER_DEFAULT_MAX_RETRIES` must be a non-negative integer"),
"{err}"
);
}
}
#[test]
fn default_max_retries_is_generator_specific_and_defaults_to_two() {
let config = parsed(
"generators:\n python:\n spec: ./a.yml\n output: ./o\n default-max-retries: 0",
);
let none = GeneratorSettings::default();
let resolved = |env: &GeneratorSettings, cli: &CliOverrides| {
resolve("python", &config, env, cli)
.unwrap()
.default_max_retries
};
assert_eq!(resolved(&none, &CliOverrides::default()), 0);
let env = env_overrides(env_get(&[("CROZIER_DEFAULT_MAX_RETRIES", "4")])).unwrap();
assert_eq!(resolved(&env, &CliOverrides::default()), 4);
let cli = CliOverrides {
default_max_retries: Some(1),
..CliOverrides::default()
};
assert!(!cli.is_empty());
assert_eq!(resolved(&env, &cli), 1);
let bare = parsed("generators:\n python:\n spec: ./a.yml\n output: ./o");
let args = resolve("python", &bare, &none, &CliOverrides::default()).unwrap();
assert_eq!(args.default_max_retries, DEFAULT_MAX_RETRIES);
let err = parse("default-max-retries: 0").unwrap_err();
assert!(err.contains("default-max-retries"), "{err}");
assert!(parse("generators:\n python:\n default-max-retries: -1").is_err());
}
#[test]
fn explain_attributes_default_max_retries_and_shows_the_default() {
let config = parsed(
"generators:\n python:\n spec: ./a.yml\n output: ./o\n default-max-retries: 0",
);
let row = |config: &FileConfig, env: &GeneratorSettings| {
explain("python", config, env, &CliOverrides::default())
.into_iter()
.find(|f| f.field == "default-max-retries")
.expect("default-max-retries present")
};
let none = GeneratorSettings::default();
let from_generator = row(&config, &none);
assert_eq!(from_generator.value.as_deref(), Some("0"));
assert_eq!(from_generator.source, Source::Generator);
let env = env_overrides(env_get(&[("CROZIER_DEFAULT_MAX_RETRIES", "3")])).unwrap();
let from_env = row(&config, &env);
assert_eq!(from_env.value.as_deref(), Some("3"));
assert_eq!(from_env.source, Source::Env);
let default = row(&FileConfig::default(), &none);
assert_eq!(default.value.as_deref(), Some("2"));
assert_eq!(default.source, Source::Default);
}
#[test]
fn merge_layers_a_generator_default_max_retries() {
let base = parsed("generators:\n python:\n default-max-retries: 0");
let over = parsed("generators:\n python:\n spec: ./a.yml");
assert_eq!(
merge(base.clone(), over).generators["python"].default_max_retries,
Some(0)
);
let over = parsed("generators:\n python:\n default-max-retries: 3");
assert_eq!(
merge(base, over).generators["python"].default_max_retries,
Some(3)
);
}
#[test]
fn merge_layers_a_generator_enum_type() {
let base = parsed("generators:\n python:\n enum-type: literals");
let over = parsed("generators:\n python:\n spec: ./a.yml");
assert_eq!(
merge(base.clone(), over).generators["python"].enum_type,
Some(EnumType::Literals)
);
let over = parsed("generators:\n python:\n enum-type: python-enums");
assert_eq!(
merge(base, over).generators["python"].enum_type,
Some(EnumType::PythonEnums)
);
}
#[test]
fn layout_layers_cli_over_env_over_generator_over_shared() {
let config = parsed(
"layout: flat\ngenerators:\n python:\n spec: ./a.yml\n output: ./o\n other:\n spec: ./a.yml\n output: ./o\n layout: packaged",
);
let none = GeneratorSettings::default();
let no_cli = CliOverrides::default();
let args = resolve("python", &config, &none, &no_cli).unwrap();
assert_eq!(args.layout, Layout::Flat);
let args = resolve("other", &config, &none, &no_cli).unwrap();
assert_eq!(args.layout, Layout::Packaged);
let env = env_overrides(env_get(&[("CROZIER_LAYOUT", "flat")])).unwrap();
let args = resolve("other", &config, &env, &no_cli).unwrap();
assert_eq!(args.layout, Layout::Flat);
let cli = CliOverrides {
layout: Some(Layout::Packaged),
..CliOverrides::default()
};
let args = resolve("other", &config, &env, &cli).unwrap();
assert_eq!(args.layout, Layout::Packaged);
let bare = parsed("spec: ./a.yml\noutput: ./o");
let args = resolve("python", &bare, &none, &no_cli).unwrap();
assert_eq!(args.layout, Layout::Packaged);
}
#[test]
fn env_layout_parses_case_insensitively_and_empty_is_unset() {
for (raw, expected) in [
("packaged", Some(Layout::Packaged)),
("FLAT", Some(Layout::Flat)),
("", None),
] {
let e = env_overrides(env_get(&[("CROZIER_LAYOUT", raw)])).unwrap();
assert_eq!(e.layout, expected, "{raw:?}");
}
}
#[test]
fn env_bad_layout_is_rejected_naming_the_variable_and_value() {
let err = env_overrides(env_get(&[("CROZIER_LAYOUT", "nested")]))
.unwrap_err()
.to_string();
assert!(err.contains("CROZIER_LAYOUT"), "{err}");
assert!(err.contains("`nested`"), "{err}");
}
#[test]
fn bad_layout_in_a_file_is_rejected_naming_the_value() {
for text in [
"layout: nested",
"generators:\n python:\n layout: nested",
] {
let err = parse(text).unwrap_err();
assert!(err.contains("nested"), "{err}");
assert!(err.contains("packaged") && err.contains("flat"), "{err}");
}
}
#[test]
fn layout_merges_per_field_across_files() {
let merged = merge(
parsed("layout: flat\ngenerators:\n python:\n layout: flat"),
parsed("generators:\n python:\n layout: packaged"),
);
assert_eq!(merged.layout, Some(Layout::Flat));
assert_eq!(merged.generators["python"].layout, Some(Layout::Packaged));
assert!(!CliOverrides {
layout: Some(Layout::Flat),
..CliOverrides::default()
}
.is_empty());
}
#[test]
fn explain_reports_layout_and_its_source() {
let config = parsed("layout: flat\ngenerators:\n python:\n spec: ./a.yml");
let none = GeneratorSettings::default();
let report = explain("python", &config, &none, &CliOverrides::default());
let layout = field_of(&report, "layout");
assert_eq!(layout.value.as_deref(), Some("flat"));
assert_eq!(layout.source, Source::Shared);
let cli = CliOverrides {
layout: Some(Layout::Packaged),
..CliOverrides::default()
};
let layout = explain("python", &config, &none, &cli)
.into_iter()
.find(|f| f.field == "layout")
.unwrap();
assert_eq!(layout.value.as_deref(), Some("packaged"));
assert_eq!(layout.source, Source::Cli);
let report = explain(
"python",
&FileConfig::default(),
&none,
&CliOverrides::default(),
);
let layout = field_of(&report, "layout");
assert_eq!(layout.value.as_deref(), Some("packaged"));
assert_eq!(layout.source, Source::Default);
assert_eq!(Layout::Flat.as_str(), "flat");
}
#[test]
fn reference_command_layers_flag_over_generator_over_shared_with_no_default() {
let config = parsed(
"reference:\n command: ./shared.sh\ngenerators:\n a:\n spec: ./a.yml\n b:\n spec: ./b.yml\n reference:\n command: ./b.sh\n",
);
assert_eq!(
resolve_reference_command("a", &config, None),
Some(("./shared.sh".to_string(), Source::Shared))
);
assert_eq!(
resolve_reference_command("b", &config, None),
Some(("./b.sh".to_string(), Source::Generator))
);
assert_eq!(
resolve_reference_command("b", &config, Some("./flag.sh")),
Some(("./flag.sh".to_string(), Source::Cli))
);
let bare = parsed("generators:\n a:\n spec: ./a.yml");
assert_eq!(resolve_reference_command("a", &bare, None), None);
let empty = parsed("reference: {}\n");
assert_eq!(resolve_reference_command("python", &empty, None), None);
let none = GeneratorSettings::default();
let cli = CliOverrides::default();
let report = explain("b", &config, &none, &cli);
let shown = field_of(&report, "reference.command");
assert_eq!(shown.value.as_deref(), Some("./b.sh"));
assert_eq!(shown.source, Source::Generator);
let report = explain("a", &bare, &none, &cli);
assert_eq!(field_of(&report, "reference.command").value, None);
}
#[test]
fn reference_block_merges_per_field_rejects_unknown_keys_and_is_ignored_by_resolve() {
let merged = merge(
parsed("reference:\n command: ./base.sh\ngenerators:\n a:\n reference:\n command: ./a.sh"),
parsed("generators:\n a:\n spec: ./a.yml\n output: ./o\n reference: {}"),
);
assert_eq!(
merged.reference.as_ref().and_then(|r| r.command.as_deref()),
Some("./base.sh")
);
assert_eq!(
merged.generators["a"]
.reference
.as_ref()
.and_then(|r| r.command.as_deref()),
Some("./a.sh")
);
let err = parse("reference:\n comand: x").unwrap_err();
assert!(err.contains("comand"), "{err}");
let with = resolve(
"a",
&merged,
&GeneratorSettings::default(),
&CliOverrides::default(),
)
.unwrap();
let mut without_config = merged.clone();
without_config.reference = None;
without_config.generators["a"].reference = None;
let without = resolve(
"a",
&without_config,
&GeneratorSettings::default(),
&CliOverrides::default(),
)
.unwrap();
assert_eq!(format!("{with:?}"), format!("{without:?}"));
}
#[test]
fn resolve_uses_top_level_for_builtin_python() {
let config = parsed("spec: ./top.yml\noutput: ./top-out");
let args = resolve(
"python",
&config,
&GeneratorSettings::default(),
&CliOverrides::default(),
)
.unwrap();
assert_eq!(args.spec, PathBuf::from("./top.yml"));
assert_eq!(args.output, PathBuf::from("./top-out"));
assert_eq!(args.package_name, None);
assert!(!args.audience_strict);
assert!(args.audiences.is_empty());
}
#[test]
fn resolve_missing_spec_and_output_error() {
let empty = FileConfig::default();
let env = GeneratorSettings::default();
let cli = CliOverrides::default();
let err = resolve("python", &empty, &env, &cli)
.unwrap_err()
.to_string();
assert!(err.contains("no spec"), "{err}");
let with_spec = parsed("spec: ./x.yml");
let err = resolve("python", &with_spec, &env, &cli)
.unwrap_err()
.to_string();
assert!(err.contains("no output"), "{err}");
}
#[test]
fn cli_overrides_is_empty() {
assert!(CliOverrides::default().is_empty());
assert!(!CliOverrides {
audience_strict: Some(true),
..CliOverrides::default()
}
.is_empty());
}
#[test]
fn discover_finds_first_by_priority() {
let dir = tempfile::tempdir().unwrap();
assert!(discover(dir.path()).is_none());
std::fs::write(dir.path().join(".crozier.yml"), "spec: ./a.yml").unwrap();
assert_eq!(
discover(dir.path()).unwrap(),
dir.path().join(".crozier.yml")
);
std::fs::write(dir.path().join("crozier.yml"), "spec: ./b.yml").unwrap();
assert_eq!(
discover(dir.path()).unwrap(),
dir.path().join("crozier.yml")
);
}
#[test]
fn load_no_config_is_empty() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("crozier.yml"), "spec: ./a.yml").unwrap();
let loaded = load(&[], true, dir.path()).unwrap();
assert_eq!(loaded.config, FileConfig::default());
assert!(loaded.files.is_empty());
}
#[test]
fn load_discovers_in_dir() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("crozier.yml"), "spec: ./a.yml").unwrap();
let loaded = load(&[], false, dir.path()).unwrap();
assert_eq!(loaded.config.spec.as_deref(), Some(Path::new("./a.yml")));
assert_eq!(loaded.files, [dir.path().join("crozier.yml")]);
}
#[test]
fn load_explicit_files_layer_in_order() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.yml");
let b = dir.path().join("b.yml");
std::fs::write(&a, "spec: ./a.yml\npackage-name: a").unwrap();
std::fs::write(&b, "package-name: b").unwrap();
let loaded = load(&[a.clone(), b.clone()], false, dir.path()).unwrap();
assert_eq!(loaded.config.package_name.as_deref(), Some("b"));
assert_eq!(loaded.config.spec.as_deref(), Some(Path::new("./a.yml")));
assert_eq!(loaded.files, [a, b]);
}
#[test]
fn load_explicit_missing_file_errors() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.yml");
let err = load(&[missing], false, dir.path()).unwrap_err().to_string();
assert!(err.contains("not found"), "{err}");
}
#[test]
fn load_invalid_file_carries_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("crozier.yml");
std::fs::write(&path, "spec: [not, a, scalar]\nbogus: 1").unwrap();
let err = load(&[], false, dir.path()).unwrap_err().to_string();
assert!(err.contains("crozier.yml"), "{err}");
}
#[test]
fn load_unreadable_file_surfaces_a_read_error() {
let dir = tempfile::tempdir().unwrap();
let err = load(&[dir.path().to_path_buf()], false, dir.path())
.unwrap_err()
.to_string();
assert!(err.contains("could not read config"), "{err}");
}
#[test]
fn env_boolean_accepts_permissive_spellings() {
for (raw, expected) in [("false", false), ("0", false), ("1", true)] {
let e = env_overrides(env_get(&[("CROZIER_AUDIENCE_STRICT", raw)])).unwrap();
assert_eq!(e.audience_strict, Some(expected), "{raw}");
}
}
#[test]
fn available_names_omits_a_configured_python() {
let c = parsed("generators:\n python:\n spec: ./a.yml");
let err = run_set(&c, Some("nope")).unwrap_err().to_string();
assert_eq!(err.matches("python").count(), 1, "{err}");
}
fn field_of<'a>(report: &'a [FieldSource], name: &str) -> &'a FieldSource {
report
.iter()
.find(|f| f.field == name)
.expect("field present")
}
#[test]
fn explain_attributes_each_field_to_its_layer() {
let config = parsed(
"spec: ./top.yml\npackage-name: top\ngenerators:\n python:\n output: ./gen-out\n package-name: gen",
);
let env = env_overrides(env_get(&[("CROZIER_PROJECT_NAME", "envproj")])).unwrap();
let cli = CliOverrides {
spec: Some(PathBuf::from("./cli.yml")),
..CliOverrides::default()
};
let report = explain("python", &config, &env, &cli);
let spec = field_of(&report, "spec");
assert_eq!(spec.value.as_deref(), Some("./cli.yml"));
assert_eq!(spec.source, Source::Cli);
let output = field_of(&report, "output");
assert_eq!(output.value.as_deref(), Some("./gen-out"));
assert_eq!(output.source, Source::Generator);
let pkg = field_of(&report, "package-name");
assert_eq!(pkg.value.as_deref(), Some("gen"));
assert_eq!(pkg.source, Source::Generator);
let proj = field_of(&report, "project-name");
assert_eq!(proj.value.as_deref(), Some("envproj"));
assert_eq!(proj.source, Source::Env);
}
#[test]
fn client_class_name_layers_and_resolves() {
let config = parsed(
"client-class-name: SharedApi\ngenerators:\n python:\n spec: ./a.yml\n output: ./o\n client-class-name: GenApi",
);
let args = resolve(
"python",
&config,
&GeneratorSettings::default(),
&CliOverrides::default(),
)
.unwrap();
assert_eq!(args.client_class_name.as_deref(), Some("GenApi"));
let env = env_overrides(env_get(&[("CROZIER_CLIENT_CLASS_NAME", "EnvApi")])).unwrap();
let report = explain("python", &config, &env, &CliOverrides::default());
let ccn = field_of(&report, "client-class-name");
assert_eq!(ccn.value.as_deref(), Some("EnvApi"));
assert_eq!(ccn.source, Source::Env);
}
#[test]
fn explain_defaults_type_and_unset_fields() {
let report = explain(
"python",
&FileConfig::default(),
&GeneratorSettings::default(),
&CliOverrides::default(),
);
let ty = field_of(&report, "type");
assert_eq!(ty.value.as_deref(), Some("python"));
assert_eq!(ty.source, Source::Default);
let spec = field_of(&report, "spec");
assert_eq!(spec.value, None);
assert_eq!(spec.source, Source::Default);
assert_eq!(field_of(&report, "audiences").value, None);
assert_eq!(field_of(&report, "audience-strict").source, Source::Default);
assert_eq!(Source::Cli.label(), "cli");
assert_eq!(Source::Shared.label(), "shared");
}
#[test]
fn fern_strict_layers_like_audience_strict_and_defaults_off() {
let bare = parsed("spec: ./a.yml\noutput: ./o");
let none = GeneratorSettings::default();
let no_cli = CliOverrides::default();
assert!(
!resolve("python", &bare, &none, &no_cli)
.unwrap()
.fern_strict
);
let shared = parsed("spec: ./a.yml\noutput: ./o\nfern-strict: true");
assert!(
resolve("python", &shared, &none, &no_cli)
.unwrap()
.fern_strict
);
let per = parsed(
"spec: ./a.yml\noutput: ./o\nfern-strict: true\ngenerators:\n python:\n fern-strict: false",
);
assert!(!resolve("python", &per, &none, &no_cli).unwrap().fern_strict);
let env = env_overrides(env_get(&[("CROZIER_FERN_STRICT", "1")])).unwrap();
assert_eq!(env.fern_strict, Some(true));
assert!(resolve("python", &per, &env, &no_cli).unwrap().fern_strict);
let env_off = env_overrides(env_get(&[("CROZIER_FERN_STRICT", "false")])).unwrap();
let cli = CliOverrides {
fern_strict: Some(true),
..CliOverrides::default()
};
assert!(!cli.is_empty());
assert!(
resolve("python", &shared, &env_off, &cli)
.unwrap()
.fern_strict
);
assert!(
!resolve("python", &shared, &env_off, &no_cli)
.unwrap()
.fern_strict
);
let merged = merge(
parsed("fern-strict: true\ngenerators:\n python:\n fern-strict: true"),
parsed("generators:\n python:\n output: ./o"),
);
assert_eq!(merged.fern_strict, Some(true));
assert_eq!(merged.generators["python"].fern_strict, Some(true));
}
#[test]
fn env_bad_fern_strict_is_rejected() {
let err = env_overrides(env_get(&[("CROZIER_FERN_STRICT", "yes")])).unwrap_err();
assert!(err.to_string().contains("CROZIER_FERN_STRICT"), "{err}");
}
#[test]
fn explain_reports_fern_strict_value_and_source() {
let report = explain(
"python",
&FileConfig::default(),
&GeneratorSettings::default(),
&CliOverrides::default(),
);
let fs = field_of(&report, "fern-strict");
assert_eq!(fs.value.as_deref(), Some("false"));
assert_eq!(fs.source, Source::Default);
let config = parsed("fern-strict: true\ngenerators:\n python:\n fern-strict: false");
let report = explain(
"python",
&config,
&GeneratorSettings::default(),
&CliOverrides::default(),
);
let fs = field_of(&report, "fern-strict");
assert_eq!(fs.value.as_deref(), Some("false"));
assert_eq!(fs.source, Source::Generator);
let report = explain(
"admin",
&config,
&GeneratorSettings::default(),
&CliOverrides::default(),
);
assert_eq!(field_of(&report, "fern-strict").source, Source::Shared);
let cli = CliOverrides {
fern_strict: Some(true),
..CliOverrides::default()
};
let report = explain("python", &config, &GeneratorSettings::default(), &cli);
let fs = field_of(&report, "fern-strict");
assert_eq!(
(fs.value.as_deref(), fs.source),
(Some("true"), Source::Cli)
);
}
}