use miette::{Diagnostic, Result};
use postgres_types::Type;
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
str::FromStr,
};
#[derive(Debug, Deserialize, Clone)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct Config {
#[serde(rename = "generate-field-metadata")]
pub generate_field_metadata: bool,
pub podman: bool,
pub queries: PathBuf,
pub destination: PathBuf,
pub sync: bool,
pub r#async: bool,
#[serde(rename = "ignore-underscore-files")]
pub ignore_underscore_files: bool,
#[serde(rename = "container-image")]
pub container_image: String,
#[serde(rename = "container-wait")]
pub container_wait: u64,
#[serde(rename = "params-only")]
pub params_only: bool,
#[serde(rename = "static")]
pub static_files: Vec<StaticFile>,
#[serde(rename = "use-workspace-deps")]
pub use_workspace_deps: UseWorkspaceDeps,
pub style: Style,
pub types: Types,
pub manifest: cargo_toml::Manifest,
}
impl Config {
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let contents = fs::read_to_string(path)?;
let mut config: Config = toml::from_str(&contents)?;
if config.manifest.package.is_none() {
config.manifest.package = default_manifest().package;
}
if let Some(manifest) = &mut config.manifest.package {
if manifest.edition == cargo_toml::Inheritable::Set(cargo_toml::Edition::E2015) {
manifest.edition = cargo_toml::Inheritable::Set(cargo_toml::Edition::E2021);
}
}
config.check_deprecated_fields();
Ok(config)
}
fn check_deprecated_fields(&self) {
if !self.types.type_traits_mapping.is_empty() {
eprintln!(
"warning: `types.type-traits-mapping` is deprecated, use `types.custom` instead"
);
}
if !self.types.type_attributes_mapping.is_empty() {
eprintln!(
"warning: `types.type-attributes-mapping` is deprecated, use `types.custom` instead"
);
}
}
pub fn builder_from_file<P: AsRef<Path>>(path: P) -> Result<ConfigBuilder, ConfigError> {
Ok(ConfigBuilder {
config: Config::from_file(path)?,
})
}
pub fn builder() -> ConfigBuilder {
ConfigBuilder::default()
}
pub(crate) fn get_type_mapping(&self, ty: &Type) -> Option<&TypeMapping> {
let key = format!("{}.{}", ty.schema(), ty.name());
self.types.mapping.get(&key)
}
}
impl Default for Config {
fn default() -> Self {
Self {
podman: false,
container_image: "docker.io/library/postgres:latest".to_string(),
generate_field_metadata: false,
container_wait: 250,
queries: PathBuf::from_str("queries/").unwrap(),
destination: PathBuf::from_str("clorinde").unwrap(),
sync: false,
r#async: true,
ignore_underscore_files: false,
params_only: false,
types: Types {
mapping: HashMap::new(),
derive_traits: vec![],
custom: HashMap::new(),
type_traits_mapping: HashMap::new(),
type_attributes_mapping: HashMap::new(),
},
manifest: default_manifest(),
style: Style::default(),
static_files: vec![],
use_workspace_deps: UseWorkspaceDeps::Bool(false),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum StaticFile {
Simple(PathBuf),
Detailed {
path: PathBuf,
#[serde(default, rename = "hard-link")]
hard_link: bool,
#[serde(rename = "destination")]
destination: Option<PathBuf>,
},
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum UseWorkspaceDeps {
Bool(bool),
Path(PathBuf),
}
impl Default for UseWorkspaceDeps {
fn default() -> Self {
UseWorkspaceDeps::Bool(false)
}
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct Types {
pub mapping: HashMap<String, TypeMapping>,
#[serde(rename = "derive-traits")]
pub derive_traits: Vec<String>,
#[serde(default)]
pub custom: HashMap<String, CustomTypeConfig>,
#[serde(rename = "type-traits-mapping", default)]
pub type_traits_mapping: HashMap<String, Vec<String>>,
#[serde(rename = "type-attributes-mapping", default)]
pub type_attributes_mapping: HashMap<String, Vec<String>>,
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct CustomTypeConfig {
#[serde(rename = "derive-traits", default)]
pub derive_traits: Vec<String>,
#[serde(default)]
pub attributes: Vec<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
#[non_exhaustive]
pub enum TypeMapping {
Simple(String),
Detailed {
#[serde(rename = "rust-type")]
rust_type: String,
#[serde(default, rename = "borrowed-type")]
borrowed_type: Option<String>,
#[serde(default = "default_true", rename = "is-copy")]
is_copy: bool,
#[serde(default)]
attributes: Vec<String>,
#[serde(default, rename = "attributes-borrowed")]
attributes_borrowed: Vec<String>,
},
}
impl TypeMapping {
pub fn get_attributes(&self) -> (Vec<String>, Vec<String>) {
match self {
TypeMapping::Simple(_) => (Vec::new(), Vec::new()),
TypeMapping::Detailed {
attributes,
attributes_borrowed,
..
} => (attributes.to_owned(), attributes_borrowed.to_owned()),
}
}
pub fn get_borrowed_type(&self) -> Option<&str> {
match self {
TypeMapping::Simple(_) => None,
TypeMapping::Detailed { borrowed_type, .. } => borrowed_type.as_deref(),
}
}
}
#[allow(deprecated)]
fn default_manifest() -> cargo_toml::Manifest {
let mut package = cargo_toml::Package::new("clorinde", "0.0.0");
package.edition = cargo_toml::Inheritable::Set(cargo_toml::Edition::E2021);
package.publish = cargo_toml::Inheritable::Set(cargo_toml::Publish::Flag(false));
cargo_toml::Manifest {
package: Some(package),
workspace: None,
dependencies: Default::default(),
dev_dependencies: Default::default(),
build_dependencies: Default::default(),
target: Default::default(),
features: Default::default(),
replace: Default::default(),
patch: Default::default(),
lib: None,
profile: cargo_toml::Profiles::default(),
badges: Default::default(),
bin: vec![],
bench: vec![],
test: vec![],
example: vec![],
lints: cargo_toml::Inheritable::default(),
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct Style {
#[serde(rename = "enum-variant-camel-case")]
pub enum_variant_camel_case: bool,
}
#[derive(Debug, Default, Clone)]
pub struct ConfigBuilder {
config: Config,
}
impl ConfigBuilder {
pub fn podman(mut self, podman: bool) -> Self {
self.config.podman = podman;
self
}
pub fn container_image(mut self, container_image: impl Into<String>) -> Self {
self.config.container_image = container_image.into();
self
}
pub fn container_wait(mut self, container_wait: u64) -> Self {
self.config.container_wait = container_wait;
self
}
pub fn queries(mut self, queries: impl Into<PathBuf>) -> Self {
self.config.queries = queries.into();
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
if let Some(package) = &mut self.config.manifest.package {
package.name = name.into();
} else {
let mut package = cargo_toml::Package::new(name.into(), "0.1.0");
package.edition = cargo_toml::Inheritable::Set(cargo_toml::Edition::E2021);
package.publish = cargo_toml::Inheritable::Set(cargo_toml::Publish::Flag(false));
self.config.manifest.package = Some(package);
}
self
}
pub fn destination(mut self, destination: impl Into<PathBuf>) -> Self {
self.config.destination = destination.into();
self
}
pub fn sync(mut self, sync: bool) -> Self {
self.config.sync = sync;
self
}
pub fn r#async(mut self, r#async: bool) -> Self {
self.config.r#async = r#async;
self
}
pub fn generate_field_metadata(mut self, generate: bool) -> Self {
self.config.generate_field_metadata = generate;
self
}
pub fn ignore_underscore_files(mut self, ignore_underscore_files: bool) -> Self {
self.config.ignore_underscore_files = ignore_underscore_files;
self
}
pub fn params_only(mut self, params_only: bool) -> Self {
self.config.params_only = params_only;
self
}
pub fn types(mut self, types: Types) -> Self {
self.config.types = types;
self
}
pub fn manifest(mut self, manifest: cargo_toml::Manifest) -> Self {
self.config.manifest = manifest;
self
}
pub fn package(mut self, package: cargo_toml::Package) -> Self {
self.config.manifest.package = Some(package);
self
}
pub fn style(mut self, style: Style) -> Self {
self.config.style = style;
self
}
pub fn add_static_file(mut self, file: StaticFile) -> Self {
self.config.static_files.push(file);
self
}
pub fn static_files(mut self, files: Vec<StaticFile>) -> Self {
self.config.static_files = files;
self
}
pub fn use_workspace_deps(mut self, use_workspace_deps: UseWorkspaceDeps) -> Self {
self.config.use_workspace_deps = use_workspace_deps;
self
}
pub fn add_type_mapping(mut self, key: impl Into<String>, mapping: TypeMapping) -> Self {
self.config.types.mapping.insert(key.into(), mapping);
self
}
pub fn add_derive_trait(mut self, trait_name: impl Into<String>) -> Self {
self.config.types.derive_traits.push(trait_name.into());
self
}
pub fn derive_traits(mut self, traits: Vec<impl Into<String>>) -> Self {
self.config.types.derive_traits = traits.into_iter().map(Into::into).collect();
self
}
pub fn add_type_trait_mapping(
mut self,
type_name: impl Into<String>,
traits: Vec<impl Into<String>>,
) -> Self {
self.config.types.type_traits_mapping.insert(
type_name.into(),
traits.into_iter().map(Into::into).collect(),
);
self
}
pub fn add_dependency(
mut self,
name: impl Into<String>,
dependency: cargo_toml::Dependency,
) -> Self {
self.config
.manifest
.dependencies
.insert(name.into(), dependency);
self
}
pub fn build(self) -> Config {
self.config
}
}
#[derive(Debug, thiserror::Error, Diagnostic)]
#[non_exhaustive]
pub enum ConfigError {
#[error("Failed to read config file: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to parse TOML: {0}")]
Toml(#[from] toml::de::Error),
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn manifest_dependencies_without_package_preserves_default_package() {
let toml_content = r#"
queries = "db/queries"
[manifest.dependencies.jiff]
version = "0.2"
"#;
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
tmpfile.write_all(toml_content.as_bytes()).unwrap();
let config = Config::from_file(tmpfile.path()).unwrap();
let package = config
.manifest
.package
.expect("package section should be preserved when only dependencies are specified");
assert_eq!(package.name, "clorinde");
assert_eq!(
package.publish,
cargo_toml::Inheritable::Set(cargo_toml::Publish::Flag(false))
);
}
#[test]
fn explicit_manifest_package_is_respected() {
let toml_content = r#"
queries = "db/queries"
[manifest.package]
name = "custom-name"
version = "1.0.0"
edition = "2021"
publish = false
"#;
let mut tmpfile = tempfile::NamedTempFile::new().unwrap();
tmpfile.write_all(toml_content.as_bytes()).unwrap();
let config = Config::from_file(tmpfile.path()).unwrap();
let package = config
.manifest
.package
.expect("package section should exist");
assert_eq!(package.name, "custom-name");
assert_eq!(package.version(), "1.0.0");
}
}