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::LoadingResult;
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;
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>,
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();
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);
}
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(),
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_string_pointers = self
.all_configurable_string_pointers
.iter()
.cloned()
.collect();
let all_configurable_list_pointers = self
.all_configurable_list_pointers
.iter()
.cloned()
.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_string_pointers,
all_configurable_list_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 validate_with_schema(&self, info: &LoadingResult) -> Result<(), String> {
let validator = match &self.schema {
Some(v) => v,
None => return Ok(()),
};
let json_value = &info.original_config;
if validator.is_valid(json_value) {
Ok(())
} else {
let errors = validator.iter_errors(json_value);
let error_messages: Vec<String> = errors.map(|e| format!("{e}")).collect();
let path = info.metadata.path.as_ref().unwrap();
Err(format!(
"Schema validation failed for {:?} : {}",
path,
error_messages.join(", ")
))
}
}
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_entry(
path: &Path,
directory: &Path,
builder_options: &BuilderOptions,
) -> Result<LoadingResult, String> {
let absolute_path = dunce::canonicalize(path).expect("path should be valid");
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.to_string_lossy(),
))
}
};
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.to_string_lossy(),
))
}
};
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.to_string_lossy(),
))
}
};
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.to_string_lossy().to_string());
metadata
}
None => OptionsMetadata::new(
None,
None,
None,
Some(canonical_feature_name.clone()),
None,
Some(absolute_path.to_string_lossy().to_string()),
),
};
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.configurable_string_pointers)
}
};
(pointers, files)
} else {
(
ConfigurableValuePointers {
configurable_string_pointers: Vec::new(),
configurable_list_pointers: Vec::new(),
},
Vec::new(),
)
};
Ok(LoadingResult {
canonical_feature_name,
conditions: feature_config.conditions,
configurable_string_files,
configurable_value_pointers,
imports: feature_config.imports,
metadata,
original_config: raw_config,
source,
})
}
fn process_loading_result(
&mut self,
loading_result: &Result<LoadingResult, String>,
) -> Result<(), String> {
let info = loading_result.as_ref()?;
let canonical_feature_name = &info.canonical_feature_name;
if self.schema.is_some() {
self.validate_with_schema(info)?;
}
if self
.sources
.insert(canonical_feature_name.clone(), info.source.clone())
.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.clone());
}
if let Some(imports) = &info.imports {
self.imports
.insert(canonical_feature_name.clone(), imports.clone());
}
if !info
.configurable_value_pointers
.configurable_string_pointers
.is_empty()
{
self.all_configurable_string_pointers.extend(
info.configurable_value_pointers
.configurable_string_pointers
.iter()
.cloned(),
);
}
if !info
.configurable_value_pointers
.configurable_list_pointers
.is_empty()
{
self.all_configurable_list_pointers.extend(
info.configurable_value_pointers
.configurable_list_pointers
.iter()
.cloned(),
);
}
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(ref aliases) = info.metadata.aliases {
for alias in aliases {
add_alias(&mut self.aliases, alias, canonical_feature_name)?;
}
}
self.features
.insert(canonical_feature_name.clone(), info.metadata.clone());
Ok(())
}
}
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;
}
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 {
Some(Ok(path.to_path_buf()))
} 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, "/");
match self.loaded_files.entry(relative_path) {
std::collections::hash_map::Entry::Occupied(entry) => {
return Some(Err(format!(
"File '{}' is already loaded from another directory.",
entry.key()
)));
}
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(contents);
}
}
}
Err(e) => {
eprintln!("Error reading file {}: {e}\nSkipping file.", path.display());
}
}
None
}
})
.collect::<Vec<_>>()
.into_par_iter()
.map(|path_result| match path_result {
Ok(path) => Self::process_entry(&path, directory, &builder_options),
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 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_string_pointers,
all_configurable_list_pointers,
self.conditions.clone(),
self.features.clone(),
referenced_file_to_feature_names,
self.loaded_files.clone(),
self.sources.clone(),
))
}
}