use std::path::PathBuf;
use clap::{Parser, Subcommand};
use crate::settings::{self, CliOverrides};
use crate::{generate, strip_python_comments, GenerateArgs};
#[derive(Parser)]
#[command(name = "crozier", version, about)]
pub struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[arg(long = "config", global = true, value_name = "PATH")]
config: Vec<PathBuf>,
#[arg(long = "no-config", global = true)]
no_config: bool,
}
#[derive(Subcommand)]
enum Command {
Generate(GenerateCmd),
Init(InitCmd),
Config(ConfigCmd),
Compare(CompareCmd),
Schema,
#[command(hide = true)]
InternalStrip {
file: PathBuf,
},
}
#[derive(Parser)]
struct InitCmd {
#[arg(long, value_name = "PATH")]
output: Option<PathBuf>,
#[arg(long)]
force: bool,
}
#[derive(Parser)]
struct ConfigCmd {
generator: Option<String>,
}
#[derive(Parser)]
struct CompareCmd {
paths: Vec<PathBuf>,
#[arg(long = "reference-command", value_name = "CMD")]
reference_command: Option<String>,
#[arg(long = "json", value_name = "PATH")]
json: Option<PathBuf>,
#[arg(long = "diff-dir", value_name = "DIR")]
diff_dir: Option<PathBuf>,
}
#[derive(Parser)]
struct GenerateCmd {
generator: Option<String>,
#[arg(long)]
spec: Option<PathBuf>,
#[arg(long)]
output: Option<PathBuf>,
#[arg(long)]
package_name: Option<String>,
#[arg(long)]
project_name: Option<String>,
#[arg(long)]
client_class_name: Option<String>,
#[arg(long = "audience")]
audiences: Vec<String>,
#[arg(long = "audience-strict")]
audience_strict: bool,
#[arg(long = "fern-strict")]
fern_strict: bool,
#[arg(long = "extra-fields", value_name = "MODE")]
extra_fields: Option<crate::settings::ExtraFields>,
#[arg(long = "enum-type", value_name = "TYPE")]
enum_type: Option<crate::settings::EnumType>,
#[arg(long = "default-max-retries", value_name = "N")]
default_max_retries: Option<u32>,
#[arg(long = "layout", value_name = "LAYOUT")]
layout: Option<crate::settings::Layout>,
}
impl GenerateCmd {
fn overrides(&self) -> CliOverrides {
CliOverrides {
spec: self.spec.clone(),
output: self.output.clone(),
package_name: self.package_name.clone(),
project_name: self.project_name.clone(),
client_class_name: self.client_class_name.clone(),
audiences: (!self.audiences.is_empty()).then(|| self.audiences.clone()),
audience_strict: self.audience_strict.then_some(true),
fern_strict: self.fern_strict.then_some(true),
extra_fields: self.extra_fields,
enum_type: self.enum_type,
default_max_retries: self.default_max_retries,
layout: self.layout,
}
}
}
pub fn parse_args<I, T>(args: I) -> std::result::Result<Cli, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let cli = Cli::try_parse_from(args)?;
if matches!(cli.command, Some(Command::Compare(_))) && (!cli.config.is_empty() || cli.no_config)
{
let mut command = <Cli as clap::CommandFactory>::command();
command.build();
let compare = command
.find_subcommand_mut("compare")
.expect("compare is a subcommand");
return Err(compare.error(
clap::error::ErrorKind::ArgumentConflict,
"`compare` finds its configs from its PATHS; pass a config file as a PATH \
instead of --config/--no-config",
));
}
Ok(cli)
}
pub fn run_from<I, T>(args: I) -> std::result::Result<(), String>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let cli = parse_args(args).map_err(|e| e.to_string())?;
run(cli)
}
pub fn run(cli: Cli) -> std::result::Result<(), String> {
match execute(cli)? {
0 => Ok(()),
code => Err(format!("exited with status {code}")),
}
}
pub fn execute(cli: Cli) -> std::result::Result<u8, String> {
if let Some(Command::Compare(cmd)) = &cli.command {
return do_compare(cmd);
}
run_other(cli).map(|()| 0)
}
fn do_compare(cmd: &CompareCmd) -> std::result::Result<u8, String> {
use std::io::IsTerminal;
let cwd = std::env::current_dir()
.map_err(|e| format!("could not read the working directory: {e}"))?;
let options = crate::compare::Options {
paths: cmd.paths.clone(),
reference_command: cmd.reference_command.clone(),
json: cmd.json.clone(),
diff_dir: cmd.diff_dir.clone(),
};
let stderr = std::io::stderr();
let color =
crate::compare::color::color_enabled(|name| std::env::var(name).ok(), stderr.is_terminal());
let mut io = crate::compare::Io {
stderr: &mut stderr.lock(),
stdout: &mut std::io::stdout().lock(),
color,
shell_supported: !cfg!(windows),
};
crate::compare::run(&options, &cwd, &mut io)
}
fn run_other(cli: Cli) -> std::result::Result<(), String> {
match cli.command {
None => do_generate(&cli.config, cli.no_config, None, &CliOverrides::default())
.map_err(|e| e.to_string()),
Some(Command::Generate(cmd)) => do_generate(
&cli.config,
cli.no_config,
cmd.generator.as_deref(),
&cmd.overrides(),
)
.map_err(|e| e.to_string()),
Some(Command::Init(cmd)) => do_init(&cmd).map_err(|e| e.to_string()),
Some(Command::Config(cmd)) => {
do_config(&cli.config, cli.no_config, cmd.generator.as_deref())
.map_err(|e| e.to_string())
}
Some(Command::Compare(_)) => unreachable!("dispatched by `execute`"),
Some(Command::Schema) => {
let json =
serde_json::to_string_pretty(&crate::schema::build()).map_err(|e| e.to_string())?;
println!("{json}");
Ok(())
}
Some(Command::InternalStrip { file }) => {
let source = std::fs::read_to_string(&file)
.map_err(|e| format!("could not read {}: {e}", file.display()))?;
print!("{}", strip_python_comments(&source));
Ok(())
}
}
}
fn do_generate(
config_paths: &[PathBuf],
no_config: bool,
selected: Option<&str>,
overrides: &CliOverrides,
) -> crate::Result<()> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let explicit = resolve_config_paths(config_paths, std::env::var("CROZIER_CONFIG").ok());
let loaded = settings::load(&explicit, no_config, &cwd)?;
let env = if no_config {
crate::settings::GeneratorSettings::default()
} else {
settings::env_overrides(|name| std::env::var(name).ok())?
};
let names = settings::run_set(&loaded.config, selected)?;
if names.len() > 1 && !overrides.is_empty() {
return Err(crate::Error::OverridesWithMultipleGenerators { count: names.len() });
}
let multiple = names.len() > 1;
for name in &names {
let args: GenerateArgs = settings::resolve(name, &loaded.config, &env, overrides)?;
let output = args.output.clone();
let files = generate(args)?;
if multiple {
eprintln!(
"{name}: generated {} files into {}",
files.len(),
output.display()
);
} else {
eprintln!("generated {} files into {}", files.len(), output.display());
}
}
Ok(())
}
const STARTER_CONFIG: &str = "\
# crozier configuration.
# Docs: https://github.com/nickderobertis/crozier/blob/main/docs/configuration.md
#
# Settings resolve per field as:
# CLI flag > CROZIER_* env > this file (generator over shared) > built-in default
#
# Top-level keys are shared defaults inherited by every generator.
spec: ./openapi.yml
# fern-strict: false # true refuses, as Fern does, documents Fern cannot generate from
generators:
# The built-in Python generator. `crozier generate python` runs this;
# `crozier` (or `crozier generate`) runs every generator listed here.
python:
type: python
output: ./sdk/python
# package-name: my_api # defaults to a snake_case of the API title
# project-name: my-api # defaults to the package name
";
fn do_init(cmd: &InitCmd) -> crate::Result<()> {
let path = cmd
.output
.clone()
.unwrap_or_else(|| PathBuf::from("crozier.yml"));
if path.exists() && !cmd.force {
return Err(crate::Error::ConfigExists { path });
}
let contents = format!("{}{STARTER_CONFIG}", crate::schema::modeline());
std::fs::write(&path, contents).map_err(|source| crate::Error::WriteConfig {
path: path.clone(),
source,
})?;
eprintln!("wrote {}", path.display());
Ok(())
}
fn do_config(
config_paths: &[PathBuf],
no_config: bool,
selected: Option<&str>,
) -> crate::Result<()> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let explicit = resolve_config_paths(config_paths, std::env::var("CROZIER_CONFIG").ok());
let loaded = settings::load(&explicit, no_config, &cwd)?;
let env = if no_config {
crate::settings::GeneratorSettings::default()
} else {
settings::env_overrides(|name| std::env::var(name).ok())?
};
if loaded.files.is_empty() {
println!("config files: none (built-in defaults)");
} else {
let files: Vec<String> = loaded
.files
.iter()
.map(|p| p.display().to_string())
.collect();
println!("config files: {}", files.join(", "));
}
let names = settings::run_set(&loaded.config, selected)?;
let empty_cli = CliOverrides::default();
for name in &names {
println!("\ngenerator `{name}`");
for f in settings::explain(name, &loaded.config, &env, &empty_cli) {
let value = f.value.as_deref().unwrap_or("(unset)");
println!(" {:<17} {:<28} ({})", f.field, value, f.source.label());
}
}
Ok(())
}
fn resolve_config_paths(config_paths: &[PathBuf], env_config: Option<String>) -> Vec<PathBuf> {
if !config_paths.is_empty() {
return config_paths.to_vec();
}
match env_config {
Some(v) if !v.is_empty() => vec![PathBuf::from(v)],
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compare_with_config_or_no_config_is_a_usage_error() {
for args in [
["crozier", "--config", "a.yml", "compare"].as_slice(),
["crozier", "--no-config", "compare"].as_slice(),
] {
let error = parse_args(args).err().unwrap();
assert_eq!(error.exit_code(), 2);
assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
assert!(error.to_string().contains("pass a config file as a PATH"));
}
assert!(parse_args(["crozier", "compare"]).is_ok());
assert!(parse_args(["crozier", "--no-config", "generate"]).is_ok());
}
#[test]
fn docs_compare_flag_table_is_compares_own_flags() {
use clap::CommandFactory;
let cli = Cli::command();
let compare = cli.find_subcommand("compare").unwrap();
let flags: Vec<String> = compare
.get_arguments()
.filter(|arg| !arg.is_global_set())
.filter_map(|arg| {
let long = arg.get_long()?;
let value = arg.get_value_names()?.first()?.to_string();
Some(format!("--{long} <{value}>"))
})
.collect();
let page = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/compare.md"),
)
.unwrap();
let documented: Vec<&str> = page
.lines()
.filter_map(|line| line.strip_prefix("| `--"))
.map(|rest| &rest[..rest.find('`').unwrap()])
.collect();
let flags: Vec<&str> = flags.iter().map(|f| &f[2..]).collect();
assert_eq!(
documented, flags,
"docs/compare.md's flag table must list `crozier compare`'s flags, in order"
);
}
#[test]
fn config_flag_beats_env_which_beats_discovery() {
let flag = [PathBuf::from("a.yml"), PathBuf::from("b.yml")];
assert_eq!(
resolve_config_paths(&flag, Some("env.yml".to_string())),
flag
);
assert_eq!(
resolve_config_paths(&[], Some("env.yml".to_string())),
[PathBuf::from("env.yml")]
);
assert!(resolve_config_paths(&[], Some(String::new())).is_empty());
assert!(resolve_config_paths(&[], None).is_empty());
}
}