use config;
use jsonschema::{Draft, Registry, Validator};
use rayon::prelude::*;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use crate::builder::builder_options::{BuilderOptions, BuilderOptionsConfig, TrackReferenceMode};
use crate::builder::extract_configurable_string_files_from_config::extract_configurable_string_files_from_config;
use crate::builder::extract_files_from_config::extract_files_from_config;
use crate::builder::get_canonical_feature_name::get_canonical_feature_name;
use crate::builder::get_supported_extensions::get_supported_extensions;
use crate::builder::loading_result::{FeatureLoadingResult, LoadingResult, RawLoadingResult};
use crate::builder::OptionsRegistryBuilder;
use crate::configurable_string::LoadedFiles;
use crate::configurable_values::locator::{find_configurable_values, ConfigurableValuePointers};
use crate::json::merge::{merge_json_with_defaults, FrozenPaths};
use crate::json::reader::read_json_from_file_as;
use crate::provider::{
Aliases, Conditions, Features, OptionsProvider, ReferencedFileToFeatureNames, Sources,
};
use crate::schema::feature::FeatureConfiguration;
use crate::schema::metadata::OptionsMetadata;
type Dependents = HashMap<String, Vec<String>>;
type Imports = HashMap<String, Vec<String>>;
#[derive(Clone)]
pub struct OptionsProviderBuilder {
aliases: Aliases,
all_configurable_string_pointers: HashSet<String>,
all_configurable_list_pointers: HashSet<String>,
keyed_configurable_list_pointers: HashMap<String, HashSet<String>>,
keyed_configurable_string_pointers: HashMap<String, HashSet<String>>,
builder_options: BuilderOptions,
conditions: Conditions,
dependents: Dependents,
features: Features,
imports: Imports,
loaded_files: LoadedFiles,
referenced_file_to_feature_names: ReferencedFileToFeatureNames,
schema: Option<Arc<Validator>>,
sources: Sources,
}
impl Default for OptionsProviderBuilder {
fn default() -> Self {
Self::new()
}
}
fn add_alias(
aliases: &mut Aliases,
alias: &String,
canonical_feature_name: &String,
) -> Result<(), String> {
let uni_case_alias = unicase::UniCase::new(alias.clone());
if let Some(ref res) = aliases.insert(uni_case_alias, canonical_feature_name.clone()) {
return Err(format!(
"The alias '{alias}' for canonical feature name '{canonical_feature_name}' is already mapped to '{res}'."
));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn resolve_imports(
canonical_feature_name: &str,
imports_for_feature: &[String],
resolved_imports: &mut HashSet<String>,
features_in_resolution_path: &mut HashSet<String>,
aliases: &Aliases,
all_dependents: &mut Dependents,
all_imports: &Imports,
sources: &mut Sources,
conditions: &Conditions,
) -> Result<(), String> {
let mut merged_config = sources.get(canonical_feature_name).unwrap().clone();
let mut frozen_paths = FrozenPaths::new();
for import in imports_for_feature.iter().rev() {
if features_in_resolution_path.contains(import) {
return Err(format!(
"Error when resolving imports for '{canonical_feature_name}': Cycle detected with import '{import}'. The features in the path (not in order): {features_in_resolution_path:?}"
));
}
if conditions.contains_key(import) {
return Err(format!(
"Error when resolving imports for '{canonical_feature_name}': The import '{import}' \
has conditions. Conditions cannot be used in imported features. This helps keep \
retrieving and building configuration options for a list of features fast and more \
predictable because imports do not need to be re-evaluated. Instead, keep each \
feature file as granular and self-contained as possible, then use conditions and \
import the required granular features in a feature file that defines a common \
scenario."
));
}
all_dependents
.entry(import.clone())
.or_default()
.push(canonical_feature_name.to_owned());
let mut source = match sources.get(import) {
Some(s) => s,
None => match aliases.get(&unicase::UniCase::new(import.clone())) {
Some(canonical_name_for_import) => {
return Err(format!(
"Error when resolving imports for '{canonical_feature_name}': The import '{import}' is not a canonical feature name. Use '{canonical_name_for_import}' instead of '{import}' in order to keep dependencies clear and to help with navigating through files."
))
}
None => {
return Err(format!(
"Error when resolving imports for '{canonical_feature_name}': The import '{import}' is not a canonical feature name and not a recognized alias. Use a canonical feature name in order to keep dependencies clear and to help with navigating through files."
))
}
},
};
if resolved_imports.insert(import.clone()) {
if let Some(imports_for_import) = all_imports.get(import) {
let mut _features_in_resolution_path = features_in_resolution_path.clone();
_features_in_resolution_path.insert(import.clone());
resolve_imports(
import,
imports_for_import,
resolved_imports,
&mut _features_in_resolution_path,
aliases,
all_dependents,
all_imports,
sources,
conditions,
)?;
source = sources.get(import).unwrap();
}
}
merge_json_with_defaults(&mut merged_config, source, &mut frozen_paths);
}
sources.insert(canonical_feature_name.to_owned(), merged_config);
Ok(())
}
impl OptionsProviderBuilder {
pub fn new() -> Self {
OptionsProviderBuilder {
aliases: Aliases::new(),
all_configurable_string_pointers: HashSet::new(),
all_configurable_list_pointers: HashSet::new(),
keyed_configurable_list_pointers: HashMap::new(),
keyed_configurable_string_pointers: HashMap::new(),
builder_options: BuilderOptions::default(),
conditions: Conditions::new(),
dependents: Dependents::new(),
features: Features::new(),
imports: HashMap::new(),
loaded_files: LoadedFiles::new(),
referenced_file_to_feature_names: HashMap::new(),
schema: None,
sources: Sources::new(),
}
}
pub fn build_and_clear(&mut self) -> Result<OptionsProvider, String> {
self.prepare_build()?;
let all_configurable_list_pointers =
std::mem::take(&mut self.all_configurable_list_pointers)
.into_iter()
.collect();
let all_configurable_string_pointers =
std::mem::take(&mut self.all_configurable_string_pointers)
.into_iter()
.collect();
let keyed_configurable_list_pointers =
std::mem::take(&mut self.keyed_configurable_list_pointers)
.into_iter()
.map(|(key, set)| (key, set.into_iter().collect()))
.collect();
let keyed_configurable_string_pointers =
std::mem::take(&mut self.keyed_configurable_string_pointers)
.into_iter()
.map(|(key, set)| (key, set.into_iter().collect()))
.collect();
let referenced_file_to_feature_names = if self.referenced_file_to_feature_names.is_empty() {
None
} else {
Some(std::mem::take(&mut self.referenced_file_to_feature_names))
};
Ok(OptionsProvider::new(
std::mem::take(&mut self.aliases),
all_configurable_list_pointers,
all_configurable_string_pointers,
keyed_configurable_list_pointers,
keyed_configurable_string_pointers,
std::mem::take(&mut self.conditions),
std::mem::take(&mut self.features),
referenced_file_to_feature_names,
std::mem::take(&mut self.loaded_files),
std::mem::take(&mut self.sources),
))
}
fn prepare_build(&mut self) -> Result<(), String> {
let mut resolved_imports: HashSet<String> = HashSet::new();
for (canonical_feature_name, imports_for_feature) in &self.imports {
if resolved_imports.insert(canonical_feature_name.clone()) {
let mut features_in_resolution_path: HashSet<String> =
HashSet::from([canonical_feature_name.clone()]);
resolve_imports(
canonical_feature_name,
imports_for_feature,
&mut resolved_imports,
&mut features_in_resolution_path,
&self.aliases,
&mut self.dependents,
&self.imports,
&mut self.sources,
&self.conditions,
)?;
}
}
for (canonical_feature_name, dependents) in &self.dependents {
let mut sorted_dependents = dependents.clone();
sorted_dependents.sort_unstable();
self.features
.get_mut(canonical_feature_name)
.unwrap()
.dependents = Some(sorted_dependents);
}
Ok(())
}
fn process_path(
path: &Path,
directory: &Path,
builder_options: &BuilderOptions,
supported_extensions: &HashSet<&str>,
feature_contents_validator: &Option<Arc<Validator>>,
) -> Result<LoadingResult, String> {
let is_config_file = match path.extension() {
Some(ext) => match ext.to_str() {
Some(ext_str) => supported_extensions.contains(ext_str),
None => false,
},
None => false,
};
if is_config_file {
process_config_file_entry(path, directory, builder_options, feature_contents_validator)
} else {
match std::fs::read_to_string(path) {
Ok(contents) => {
let relative_path = path
.strip_prefix(directory)
.unwrap()
.to_str()
.expect("path should be valid Unicode")
.replace(std::path::MAIN_SEPARATOR, "/");
Ok(LoadingResult::Raw(RawLoadingResult {
contents,
relative_path,
}))
}
Err(e) => Err(format!("Error reading file {}: {e}", path.display())),
}
}
}
fn process_loading_result(
&mut self,
loading_result: Result<LoadingResult, String>,
) -> Result<(), String> {
match loading_result? {
LoadingResult::Feature(feature) => self.process_feature_loading_result(feature),
LoadingResult::Raw(raw) => self.process_raw_loading_result(raw),
}
}
fn process_feature_loading_result(&mut self, info: FeatureLoadingResult) -> Result<(), String> {
let canonical_feature_name = info.canonical_feature_name;
if self
.sources
.insert(canonical_feature_name.clone(), info.source)
.is_some()
{
return Err(format!(
"Error when loading feature. The canonical feature name '{canonical_feature_name}' was already added. It may be an alias for another feature."
));
}
if let Some(conditions) = info.conditions {
self.conditions
.insert(canonical_feature_name.clone(), conditions);
}
if let Some(imports) = info.imports {
self.imports.insert(canonical_feature_name.clone(), imports);
}
self.process_loaded_configurable_value_pointers(info.configurable_value_pointers);
for file_key in &info.configurable_string_files {
self.referenced_file_to_feature_names
.entry(file_key.clone())
.or_default()
.push(canonical_feature_name.clone());
}
add_alias(
&mut self.aliases,
&canonical_feature_name,
&canonical_feature_name,
)?;
if let Some(aliases) = &info.metadata.aliases {
for alias in aliases {
add_alias(&mut self.aliases, alias, &canonical_feature_name)?;
}
}
self.features.insert(canonical_feature_name, info.metadata);
Ok(())
}
fn process_raw_loading_result(&mut self, raw_result: RawLoadingResult) -> Result<(), String> {
match self.loaded_files.entry(raw_result.relative_path) {
std::collections::hash_map::Entry::Occupied(entry) => {
return Err(format!(
"File '{}' is already loaded from another directory.",
entry.key()
));
}
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(raw_result.contents);
}
}
Ok(())
}
fn process_loaded_configurable_value_pointers(&mut self, pointers: ConfigurableValuePointers) {
if !pointers.configurable_list_pointers.is_empty() {
self.all_configurable_list_pointers
.extend(pointers.configurable_list_pointers);
}
if !pointers.configurable_string_pointers.is_empty() {
self.all_configurable_string_pointers
.extend(pointers.configurable_string_pointers);
}
if !pointers.keyed_configurable_list_pointers.is_empty() {
for (key, keyed_pointers) in pointers.keyed_configurable_list_pointers {
self.keyed_configurable_list_pointers
.entry(key)
.and_modify(|dest_set| dest_set.extend(keyed_pointers.clone()))
.or_insert(keyed_pointers.into_iter().collect());
}
}
if !pointers.keyed_configurable_string_pointers.is_empty() {
for (key, keyed_pointers) in pointers.keyed_configurable_string_pointers {
self.keyed_configurable_string_pointers
.entry(key)
.and_modify(|dest_set| dest_set.extend(keyed_pointers.clone()))
.or_insert(keyed_pointers.into_iter().collect());
}
}
}
}
fn validate_with_schema(
validator: &Option<Arc<Validator>>,
original_config: &serde_json::Value,
path: &String,
) -> Result<(), String> {
match validator {
Some(validator) => {
if validator.is_valid(original_config) {
Ok(())
} else {
let errors = validator.iter_errors(original_config);
let error_messages: Vec<String> = errors.map(|e| format!("{e}")).collect();
Err(format!(
"Schema validation failed for {:?} : {}",
path,
error_messages.join(", ")
))
}
}
None => Ok(()),
}
}
fn process_config_file_entry(
path: &Path,
directory: &Path,
builder_options: &BuilderOptions,
feature_contents_validator: &Option<Arc<Validator>>,
) -> Result<LoadingResult, String> {
let absolute_path = dunce::canonicalize(path)
.expect("path should be valid")
.to_string_lossy()
.to_string();
let file = config::File::from(path);
let config_for_path = match config::Config::builder().add_source(file).build() {
Ok(conf) => conf,
Err(e) => return Err(format!("Error loading file '{}': {e}", absolute_path)),
};
let raw_config: serde_json::Value = match config_for_path.try_deserialize() {
Ok(v) => v,
Err(e) => {
return Err(format!(
"Error deserializing configuration for file '{}': {e}",
absolute_path,
))
}
};
validate_with_schema(feature_contents_validator, &raw_config, &absolute_path)?;
let feature_config: FeatureConfiguration = match serde_json::from_value(raw_config.clone()) {
Ok(v) => v,
Err(e) => {
return Err(format!(
"Error deserializing configuration for file '{}': {e}",
absolute_path,
))
}
};
let source = feature_config
.options
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
let canonical_feature_name = get_canonical_feature_name(path, directory);
let metadata = match feature_config.metadata {
Some(mut metadata) => {
metadata.name = Some(canonical_feature_name.clone());
metadata.path = Some(absolute_path);
metadata
}
None => OptionsMetadata::new(
None,
None,
None,
Some(canonical_feature_name.clone()),
None,
Some(absolute_path),
),
};
let (configurable_value_pointers, configurable_string_files) =
if builder_options.are_configurable_strings_enabled {
let pointers = find_configurable_values(raw_config.get("options"));
let files = match builder_options.track_file_references {
TrackReferenceMode::None => Vec::new(),
TrackReferenceMode::ConfigurableStrings => {
extract_configurable_string_files_from_config(
&raw_config,
&pointers.configurable_string_pointers,
)
}
TrackReferenceMode::KeyName => extract_files_from_config(&raw_config),
};
(pointers, files)
} else {
(ConfigurableValuePointers::default(), Vec::new())
};
Ok(LoadingResult::Feature(FeatureLoadingResult {
canonical_feature_name,
conditions: feature_config.conditions,
configurable_string_files,
configurable_value_pointers,
imports: feature_config.imports,
metadata,
source,
}))
}
impl OptionsRegistryBuilder<OptionsProvider> for OptionsProviderBuilder {
fn add_directories(&mut self, directories: &[impl AsRef<Path>]) -> Result<&Self, String> {
for directory in directories {
self.add_directory(directory)?;
}
Ok(self)
}
fn add_directory(&mut self, directory: impl AsRef<Path>) -> Result<&Self, String> {
let directory = directory.as_ref();
if !directory.is_dir() {
return Err(format!(
"Error adding directory: {directory:?} is not a directory"
));
}
let config_path = directory.join(".optify").join("config.json");
let builder_options = if config_path.is_file() {
read_json_from_file_as::<BuilderOptionsConfig>(&config_path)
.map_err(|e| {
format!(
"Error loading builder options from {}: {e}",
config_path.as_path().display()
)
})?
.merge_with(&self.builder_options)
} else {
self.builder_options.clone()
};
let supported_extensions = get_supported_extensions();
let loading_results: Vec<Result<LoadingResult, String>> = walkdir::WalkDir::new(directory)
.into_iter()
.filter_map(|entry| {
let entry = entry
.unwrap_or_else(|_| panic!("Error walking directory: {}", directory.display()));
let path = entry.path();
if !path.is_file() {
return None;
}
if path
.components()
.any(|component| component.as_os_str() == ".optify")
{
return None;
}
Some(Ok(path.to_path_buf()))
})
.collect::<Vec<_>>()
.into_par_iter()
.map(|path_result| match path_result {
Ok(path) => Self::process_path(
&path,
directory,
&builder_options,
&supported_extensions,
&self.schema,
),
Err(e) => Err(e),
})
.collect();
for loading_result in loading_results {
self.process_loading_result(loading_result)?;
}
Ok(self)
}
fn with_options(&mut self, options: BuilderOptions) -> Result<&Self, String> {
if let Some(ref schema_path) = options.schema_path {
self.with_schema(schema_path)?;
}
self.builder_options = options;
Ok(self)
}
fn with_schema(&mut self, schema_path: impl AsRef<Path>) -> Result<&Self, String> {
let schema_path = schema_path.as_ref();
let schema_json = crate::json::reader::read_json_from_file(schema_path)
.map_err(|e| format!("Failed to read schema file: {e}"))?;
const EMBEDDED_SCHEMA: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/schemas/feature_file.json"));
let optify_schema_json: serde_json::Value = serde_json::from_slice(EMBEDDED_SCHEMA)
.map_err(|e| format!("Failed to parse embedded schema: {e}"))?;
let registry = Registry::new()
.add(
"https://raw.githubusercontent.com/juharris/optify/refs/heads/main/schemas/feature_file.json",
optify_schema_json,
)
.map_err(|e| format!("Failed to add schema resource to registry: {e}"))?
.prepare()
.map_err(|e| format!("Failed to prepare schema registry: {e}"))?;
let validator = Validator::options()
.with_draft(Draft::Draft7)
.with_registry(®istry)
.build(&schema_json)
.map_err(|e| format!("Invalid schema: {e}"))?;
self.schema = Some(Arc::new(validator));
Ok(self)
}
fn build(&mut self) -> Result<OptionsProvider, String> {
self.prepare_build()?;
let all_configurable_string_pointers = self
.all_configurable_string_pointers
.iter()
.cloned()
.collect();
let all_configurable_list_pointers = self
.all_configurable_list_pointers
.iter()
.cloned()
.collect();
let keyed_configurable_list_pointers = self
.keyed_configurable_list_pointers
.iter()
.map(|(key, set)| (key.clone(), set.iter().cloned().collect()))
.collect();
let keyed_configurable_string_pointers = self
.keyed_configurable_string_pointers
.iter()
.map(|(key, set)| (key.clone(), set.iter().cloned().collect()))
.collect();
let referenced_file_to_feature_names = if self.referenced_file_to_feature_names.is_empty() {
None
} else {
Some(self.referenced_file_to_feature_names.clone())
};
Ok(OptionsProvider::new(
self.aliases.clone(),
all_configurable_list_pointers,
all_configurable_string_pointers,
keyed_configurable_list_pointers,
keyed_configurable_string_pointers,
self.conditions.clone(),
self.features.clone(),
referenced_file_to_feature_names,
self.loaded_files.clone(),
self.sources.clone(),
))
}
}