/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ use std::{collections::HashMap, fs}; use anyhow::{Context, Result}; use camino::{Utf8Path, Utf8PathBuf}; use serde::Deserialize; use crate::{merge_toml, BindgenPaths, BindgenPathsLayer}; /// A [BindgenPathsLayer] backed by an explicit `[crate-roots]` mapping. pub struct CrateRootsLayer { roots: HashMap, } impl BindgenPathsLayer for CrateRootsLayer { fn get_crate_root(&self, crate_name: &str) -> Option { self.roots.get(crate_name).cloned() } } /// Deserialized representation of a global config TOML file. #[derive(Deserialize, Default)] struct GlobalConfigFile { #[serde(rename = "crate-roots", default)] crate_roots: HashMap, #[serde(default)] defaults: toml::value::Table, #[serde(default)] crates: HashMap, } /// Global configuration for UniFFI. /// /// Holds defaults and per-crate overrides from a global config file. /// Config resolution requires a `&BindgenPaths` to locate per-crate `uniffi.toml` files. #[derive(Default)] pub struct GlobalConfig { defaults: toml::value::Table, crate_overrides: HashMap, } impl GlobalConfig { /// Parse a global config file. /// /// Returns the `GlobalConfig` and an optional `CrateRootsLayer`. If a /// `CrateRootsLayer` is returned, add it to your `BindgenPaths` before /// calling `get_config`. pub fn from_file(path: &Utf8Path) -> Result<(Self, Option)> { let contents = fs::read_to_string(path).with_context(|| format!("read file: {:?}", path))?; // Check for old-style flat config files before full parse let raw: toml::value::Table = toml::de::from_str(&contents).with_context(|| format!("parse toml: {:?}", path))?; let has_global_config_keys = raw.contains_key("crate-roots") || raw.contains_key("defaults") || raw.contains_key("crates"); if !has_global_config_keys && !raw.is_empty() { eprintln!( "warning: {path} looks like an old-style --config override file. \ The --config flag now expects a global config file with [defaults], \ [crates.], and/or [crate-roots] sections. \ Old-style flat config files are no longer supported." ); return Ok((Self::default(), None)); } let file: GlobalConfigFile = toml::de::from_str(&contents).with_context(|| format!("parse toml: {:?}", path))?; let crate_roots_layer = if file.crate_roots.is_empty() { None } else { let base_dir = path.parent().unwrap_or(Utf8Path::new(".")); let roots = file .crate_roots .into_iter() .map(|(name, rel_path)| (name, base_dir.join(rel_path))) .collect(); Some(CrateRootsLayer { roots }) }; Ok(( Self { defaults: file.defaults, crate_overrides: file.crates, }, crate_roots_layer, )) } /// Get the merged config table for a crate. /// Recursively and structurally applies TOML, later wins: /// `[defaults]` from the global config; then the crate's `uniffi.toml` via `BindgenPaths; /// then `[crates.{name}]` from the global config. pub fn get_config(&self, paths: &BindgenPaths, crate_name: &str) -> Result { let mut config = self.defaults.clone(); if let Some(config_path) = paths.get_config_path(crate_name) { if config_path.exists() { let contents = fs::read_to_string(&config_path) .with_context(|| format!("read file: {:?}", config_path))?; let crate_config: toml::value::Table = toml::de::from_str(&contents) .with_context(|| format!("parse toml: {:?}", config_path))?; merge_toml(&mut config, crate_config)?; } } if let Some(overrides) = self.crate_overrides.get(crate_name) { merge_toml(&mut config, overrides.clone())?; } Ok(config) } } #[cfg(test)] mod tests { use super::*; use camino::Utf8PathBuf; use tempfile::TempDir; // A simple BindgenPathsLayer that maps one crate to a fixed root path. struct StaticLayer { crate_name: String, crate_root: Utf8PathBuf, } impl BindgenPathsLayer for StaticLayer { fn get_crate_root(&self, crate_name: &str) -> Option { if crate_name == self.crate_name { Some(self.crate_root.clone()) } else { None } } } fn write_file(dir: &TempDir, name: &str, contents: &str) -> Utf8PathBuf { let path = Utf8PathBuf::from_path_buf(dir.path().join(name)).unwrap(); fs::write(&path, contents).unwrap(); path } fn paths_for(crate_name: &str, root: &Utf8PathBuf) -> BindgenPaths { let mut paths = BindgenPaths::default(); paths.add_layer(StaticLayer { crate_name: crate_name.to_string(), crate_root: root.clone(), }); paths } #[test] fn test_defaults_fill_in_absent_keys() { // Keys in [defaults] that the crate config doesn't set should appear in the merged result. let dir = TempDir::new().unwrap(); let global_path = write_file( &dir, "global.toml", r#" [defaults] key_only_in_defaults = "sentinel" "#, ); let crate_root = Utf8PathBuf::from_path_buf(dir.path().join("my_crate")).unwrap(); fs::create_dir_all(&crate_root).unwrap(); let paths = paths_for("my_crate", &crate_root); let (config, _) = GlobalConfig::from_file(&global_path).unwrap(); let table = config.get_config(&paths, "my_crate").unwrap(); assert_eq!(table["key_only_in_defaults"].as_str().unwrap(), "sentinel"); } #[test] fn test_empty_global_config() { let dir = TempDir::new().unwrap(); let paths = paths_for( "my_crate", &Utf8PathBuf::from_path_buf(dir.path().to_owned()).unwrap(), ); let config = GlobalConfig::default(); let table = config.get_config(&paths, "my_crate").unwrap(); assert!(table.is_empty()); } #[test] fn test_defaults_only() { let dir = TempDir::new().unwrap(); let global_path = write_file( &dir, "global.toml", r#" [defaults.bindings.swift] ffi_module_name = "MyFFI" "#, ); let (config, roots_layer) = GlobalConfig::from_file(&global_path).unwrap(); assert!(roots_layer.is_none()); // Point to a crate root with no uniffi.toml let crate_root = Utf8PathBuf::from_path_buf(dir.path().join("my_crate")).unwrap(); fs::create_dir_all(&crate_root).unwrap(); let paths = paths_for("my_crate", &crate_root); let table = config.get_config(&paths, "my_crate").unwrap(); assert_eq!( table["bindings"]["swift"]["ffi_module_name"] .as_str() .unwrap(), "MyFFI" ); } #[test] fn test_crate_config_overrides_defaults() { let dir = TempDir::new().unwrap(); let global_path = write_file( &dir, "global.toml", r#" [defaults] shared_key = "default_value" default_only_key = "sentinel" "#, ); let crate_root = Utf8PathBuf::from_path_buf(dir.path().join("my_crate")).unwrap(); fs::create_dir_all(&crate_root).unwrap(); // crate's uniffi.toml overrides shared_key but not default_only_key fs::write( crate_root.join("uniffi.toml"), r#" shared_key = "crate_value" "#, ) .unwrap(); let (config, _) = GlobalConfig::from_file(&global_path).unwrap(); let paths = paths_for("my_crate", &crate_root); let table = config.get_config(&paths, "my_crate").unwrap(); // crate wins on the overlapping key assert_eq!(table["shared_key"].as_str().unwrap(), "crate_value"); // default fills in the rest assert_eq!(table["default_only_key"].as_str().unwrap(), "sentinel"); } #[test] fn test_per_crate_overrides_win() { let dir = TempDir::new().unwrap(); let global_path = write_file( &dir, "global.toml", r#" [defaults.bindings.swift] ffi_module_name = "DefaultFFI" [crates.my_crate.bindings.swift] ffi_module_name = "OverrideFFI" ffi_module_filename = "my_crate_ffi" "#, ); let crate_root = Utf8PathBuf::from_path_buf(dir.path().join("my_crate")).unwrap(); fs::create_dir_all(&crate_root).unwrap(); fs::write( crate_root.join("uniffi.toml"), r#" [bindings.swift] ffi_module_name = "CrateFFI" "#, ) .unwrap(); let (config, _) = GlobalConfig::from_file(&global_path).unwrap(); let paths = paths_for("my_crate", &crate_root); let table = config.get_config(&paths, "my_crate").unwrap(); // per-crate override wins over both default and crate uniffi.toml assert_eq!( table["bindings"]["swift"]["ffi_module_name"] .as_str() .unwrap(), "OverrideFFI" ); // per-crate override also adds new keys assert_eq!( table["bindings"]["swift"]["ffi_module_filename"] .as_str() .unwrap(), "my_crate_ffi" ); } #[test] fn test_deep_merge() { let dir = TempDir::new().unwrap(); let global_path = write_file( &dir, "global.toml", r#" [defaults.bindings.swift] ffi_module_name = "SharedFFI" [crates.my_crate.bindings.kotlin] package_name = "com.example" "#, ); let crate_root = Utf8PathBuf::from_path_buf(dir.path().join("my_crate")).unwrap(); fs::create_dir_all(&crate_root).unwrap(); fs::write( crate_root.join("uniffi.toml"), r#" [bindings.swift] ffi_module_filename = "my_crate_ffi" "#, ) .unwrap(); let (config, _) = GlobalConfig::from_file(&global_path).unwrap(); let paths = paths_for("my_crate", &crate_root); let table = config.get_config(&paths, "my_crate").unwrap(); let bindings = &table["bindings"]; // swift keys from both default and crate uniffi.toml are present assert_eq!( bindings["swift"]["ffi_module_name"].as_str().unwrap(), "SharedFFI" ); assert_eq!( bindings["swift"]["ffi_module_filename"].as_str().unwrap(), "my_crate_ffi" ); // kotlin key from per-crate override is present assert_eq!( bindings["kotlin"]["package_name"].as_str().unwrap(), "com.example" ); } #[test] fn test_crate_roots_layer() { let dir = TempDir::new().unwrap(); let sub = dir.path().join("crates").join("my_crate"); fs::create_dir_all(&sub).unwrap(); let global_path = write_file( &dir, "global.toml", r#" [crate-roots] my_crate = "crates/my_crate" "#, ); let (config, roots_layer) = GlobalConfig::from_file(&global_path).unwrap(); let roots_layer = roots_layer.expect("expected CrateRootsLayer"); let mut paths = BindgenPaths::default(); paths.add_layer(roots_layer); // The crate root should resolve to an absolute path under dir let root = paths.get_crate_root("my_crate").unwrap(); assert!(root.is_absolute()); assert!(root.ends_with("crates/my_crate")); // Config path should be {root}/uniffi.toml let config_path = paths.get_config_path("my_crate").unwrap(); assert!(config_path.ends_with("uniffi.toml")); // get_config works even with no uniffi.toml present let table = config.get_config(&paths, "my_crate").unwrap(); assert!(table.is_empty()); } #[test] fn test_old_style_config_returns_default() { let dir = TempDir::new().unwrap(); // Old-style: flat keys not under [defaults], [crates.*], or [crate-roots] let global_path = write_file( &dir, "old.toml", r#" [bindings.swift] ffi_module_name = "OldFFI" "#, ); let (config, roots_layer) = GlobalConfig::from_file(&global_path).unwrap(); assert!(roots_layer.is_none()); let crate_root = Utf8PathBuf::from_path_buf(dir.path().join("my_crate")).unwrap(); fs::create_dir_all(&crate_root).unwrap(); let paths = paths_for("my_crate", &crate_root); // Old-style config is ignored; result is empty let table = config.get_config(&paths, "my_crate").unwrap(); assert!(table.is_empty()); } }