diff --git a/.prototools b/.prototools new file mode 100644 index 0000000..87cceb9 --- /dev/null +++ b/.prototools @@ -0,0 +1 @@ +rust = "1.82.0" diff --git a/Cargo.lock b/Cargo.lock index f8a355d..a54e543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,18 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -668,6 +680,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -675,6 +690,15 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -750,6 +774,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.159" @@ -1286,7 +1316,7 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "weaveconfig" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "biome_formatter", @@ -1296,7 +1326,9 @@ dependencies = [ "clap", "fjson", "futures", + "hashlink", "jemallocator", + "lazy_static", "regex", "serde", "serde_json", @@ -1385,3 +1417,23 @@ name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] diff --git a/Cargo.toml b/Cargo.toml index cc80cab..2bd5b72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "weaveconfig" -version = "0.4.0" +version = "0.5.0" edition = "2021" description = "A unified configuration tool for monorepos" readme = "README.md" @@ -16,6 +16,8 @@ biome_js_syntax = "0.5.7" clap = { version = "4.5.18", features = ["cargo", "derive"] } fjson = "0.3.1" futures = "0.3.30" +hashlink = "0.9.1" +lazy_static = "1.5.0" regex = "1.11.0" serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" diff --git a/README.md b/README.md index b00c879..40fa6d2 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Weaveconfig is a configuration tool for monorepos. It allows you to manage all configuration in a single directory in the root of your project. -To use it just run `weaveconfig` in the root of your project, to create the initial configuration run `weaveconfig init`. +To use it just run `weaveconfig gen` in the root of your project, to create the initial configuration run `weaveconfig init`. The weaveconfig contains 3 kinds of files: @@ -10,3 +10,48 @@ The weaveconfig contains 3 kinds of files: - `_env.jsonc` - This file contains the configuration / variables for the space. - other files - These files will be copied into each space inlined with variables from the space. +## \_space.jsonc + +The `_space.jsonc` file defines a configuration space and supports the following fields: + +- `name` (required): A unique identifier for the space, used for dependency references. Must be unique across all spaces. + +- `dependencies` (optional): An array of other space names that this space depends on. The referenced spaces must exist within the weaveconfig directory. Circular dependencies are not allowed. If the environment names of the dependency don't match they will be remapped based on the equvalent in the root space. + +- `environments` (optional): An array of environment names supported by this space (e.g. "development", "staging", "production"). These names are used in mappings and must be unique within the space. +- `space_to_parent_mapping` (optional): Maps environments in this space to environments in the parent space. For root spaces (those without a parent), this maps to the ENV variable values. For non-root spaces, this maps to environments in the closest parent space (nearest ancestor directory with \_space.jsonc). If omitted, environments are inherited as-is from the parent. + + Example: `{"prod": ["prod1", "prod2"], "dev": ["dev"]}` + +- `generate` (optional): Controls configuration generation options: + - Can be a boolean to toggle all generation + - Or an object with: + - `typescript`: Boolean to toggle TypeScript binding generation + +When generation is enabled, it creates: + +- `gen/config.json`: Contains the resolved configuration +- `gen/binding.ts`: Provides type-safe access to the configuration +- `gen/.gitignore`: Ignores the generated files from the git index, it's recommended to ignore the whole gen folder rather than just individual files. + +## \_env.jsonc + +The `_env.jsonc` file contains the actual configuration variables for a space. It supports: + +- Shared variables in as JSON, these are available in all environments +- Environment-specific variables using `_.env.jsonc` files (e.g. `_prod.env.jsonc`) +- Variables are merged hierarchically from parent spaces to child spaces +- JSON/JSONC format is supported for both file types + +The variables defined in these files will be: + +1. Merged according to the space hierarchy +2. Made available in the generated config.json +3. Accessible via the TypeScript bindings when enabled +4. Used to substitute values in other files that are copied to the space from the weaveconfig directory + +## Runtime + +weaveconfig runs purely at build time generating a config that contains variables for all environments at the same time. +This means that the runtime of your application must choose which environment to use at runtime. +The recommended way to do this is to use the `ENV` environment variable, this is also what the TypeScript binding expects. diff --git a/schema.json b/schema.json index d14f982..aa04f18 100644 --- a/schema.json +++ b/schema.json @@ -23,27 +23,18 @@ }, "uniqueItems": true }, - "mapping": { - "type": "array", - "description": "Defines how environments from the parent and dependency spaces map to environments in this space.\n\nFor root spaces (those without a parent space), this maps environment names from the ENV variable and dependency spaces to the space's environments.\nFor non-root spaces, this maps environment names from the closest parent space (nearest ancestor directory with _space.jsonc) and dependency spaces to the space's environments.\n\nMappings are optional:\n- If omitted, all parent/dependency environments are inherited as-is\n- If provided, you can map all or a subset of environments. Unmapped environments are inherited from the parent\n\nExample: [{\"parent\": \"prod\", \"this\": \"production\"}, {\"parent\": \"dev\", \"this\": \"development\"}]", - "items": { - "type": "object", - "properties": { - "from": { - "type": "string", - "description": "Environment name from the parent space (or ENV variable for root spaces) or dependency space.", - "minLength": 1 - }, - "this": { - "type": "string", - "description": "Corresponding environment name in this space. Must exist in this space's environments array.", - "minLength": 1 - } + "space_to_parent_mapping": { + "type": "object", + "description": "Maps environments in this space to environments in the parent space. For root spaces (those without a parent), this maps to the ENV variable values. For non-root spaces, this maps to environments in the closest parent space (nearest ancestor directory with _space.jsonc).\n\nIf omitted, environments are inherited as-is from the parent.\n\nExample: {\"production\": [\"prod\", \"prod-dr\"], \"development\": [\"dev\"]}", + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 }, - "required": ["parent", "this"], - "additionalProperties": false - }, - "uniqueItems": true + "uniqueItems": true, + "minItems": 1 + } }, "environments": { "type": "array", @@ -52,8 +43,7 @@ "type": "string", "minLength": 1 }, - "uniqueItems": true, - "minItems": 1 + "uniqueItems": true }, "generate": { "description": "Configuration generation options for this space. When enabled, generates:\n- gen/config.json: Contains the resolved configuration\n- gen/binding.ts: Provides type-safe access to the configuration", diff --git a/src/ancestor_mapping.rs b/src/ancestor_mapping.rs new file mode 100644 index 0000000..008cfca --- /dev/null +++ b/src/ancestor_mapping.rs @@ -0,0 +1,528 @@ +use lazy_static::lazy_static; +use std::collections::{HashMap, HashSet}; +use thiserror::Error; + +lazy_static! { + static ref DEFAULT_ANCESTOR_SET: HashSet = HashSet::new(); +} + +/// A mapping between ancestor environments and space environments. +/// Every space environment must at most be mapped to one ancestor environment. +/// But multiple ancestor environments can map to the same space environment. +/// For example, you might have the ancestor environments: dev, test, prod1, prod2 +/// And the space environments: dev, test, prod +/// Then you might have the following ancestor to space mappings: +/// dev -> dev +/// test -> test +/// prod1 -> prod +/// prod2 -> prod +/// +/// And the following space to ancestor mappings: +/// dev -> dev +/// test -> test +/// prod -> [prod1, prod2] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AncestorMapping { + /// Maps a space environment to a set of ancestor environments. + space_to_ancestor: HashMap>, + /// Maps an ancestor environment to a space environment. + ancestor_to_space: HashMap, +} + +#[derive(Error, Debug)] +pub enum RootMappingError { + #[error("Mapping for ancestor '{0}' already exists and cannot be overwritten")] + DuplicateAncestor(String), +} + +impl AncestorMapping { + /// Creates a new, empty `AncestorMapping`. + pub fn new() -> Self { + AncestorMapping { + space_to_ancestor: HashMap::new(), + ancestor_to_space: HashMap::new(), + } + } + + pub fn from_space_to_ancestors( + space_to_ancestors: HashMap>, + ) -> Result { + let mut mapping = AncestorMapping::new(); + for (space, ancestors) in space_to_ancestors { + for ancestor in ancestors { + mapping.add_mapping(ancestor, space.clone())?; + } + } + Ok(mapping) + } + + /// Attempts to add a mapping from an ancestor environment to a space environment. + /// + /// If the ancestor already exists, returns an error and does not overwrite the existing mapping. + /// If the space does not exist, it is created. + /// + /// # Arguments + /// + /// * `ancestor` - The ancestor environment name. + /// * `space` - The space environment name. + /// + /// # Returns + /// + /// * `Ok(())` if the mapping was added successfully. + /// * `Err(RootMappingError)` if the ancestor already exists. + /// + /// # Example + /// + /// ```rust + /// root_mapping.add_mapping("prod1".to_string(), "prod".to_string()).unwrap(); + /// ``` + pub fn add_mapping(&mut self, ancestor: String, space: String) -> Result<(), RootMappingError> { + if self.ancestor_to_space.contains_key(&ancestor) { + return Err(RootMappingError::DuplicateAncestor(ancestor)); + } + + // Add the ancestor to the ancestor_to_space map. + self.ancestor_to_space + .insert(ancestor.clone(), space.clone()); + + // Add the ancestor to the space_to_ancestor map. + self.space_to_ancestor + .entry(space) + .or_insert_with(HashSet::new) + .insert(ancestor); + + Ok(()) + } + + /// Replaces an existing mapping from an ancestor environment to a new space environment. + /// + /// If the ancestor does not exist, returns `None` without adding a new mapping. + /// If the ancestor exists, replaces the mapping and returns the previous space. + /// + /// # Arguments + /// + /// * `ancestor` - The ancestor environment name to replace. + /// * `new_space` - The new space environment name. + /// + /// # Returns + /// + /// * `Some(String)` containing the previous space environment if the mapping was replaced. + /// * `None` if the ancestor does not exist. + /// + /// # Example + /// + /// ```rust + /// let previous = root_mapping.replace_mapping("prod1".to_string(), "staging".to_string()); + /// ``` + pub fn replace_mapping(&mut self, ancestor: String, new_space: String) -> Option { + // Remove the existing mapping if it exists. + if let Some(existing_space) = self.ancestor_to_space.get_mut(&ancestor) { + // Remove the ancestor from the old space's set. + if let Some(ancestors) = self.space_to_ancestor.get_mut(existing_space) { + ancestors.remove(&ancestor); + if ancestors.is_empty() { + self.space_to_ancestor.remove(existing_space); + } + } + + // Update the ancestor_to_space with the new space. + let previous_space = self + .ancestor_to_space + .insert(ancestor.clone(), new_space.clone()); + + // Add the ancestor to the new space's set. + self.space_to_ancestor + .entry(new_space) + .or_insert_with(HashSet::new) + .insert(ancestor.clone()); + + // Return the previous space. + previous_space + } else { + // Ancestor does not exist; do not add a new mapping. + None + } + } + + /// Removes a mapping by the ancestor environment. + /// + /// # Arguments + /// + /// * `ancestor` - The ancestor environment name to remove. + /// + /// # Returns + /// + /// `true` if the mapping was found and removed, `false` otherwise. + /// + /// # Example + /// + /// ```rust + /// let removed = root_mapping.remove_mapping_by_ancestor("prod1".to_string()); + /// ``` + pub fn remove_mapping_by_ancestor(&mut self, ancestor: &String) -> bool { + if let Some(space) = self.ancestor_to_space.remove(ancestor) { + if let Some(ancestors) = self.space_to_ancestor.get_mut(&space) { + ancestors.remove(ancestor); + if ancestors.is_empty() { + self.space_to_ancestor.remove(&space); + } + } + true + } else { + false + } + } + + /// Removes all mappings associated with a space environment. + /// + /// # Arguments + /// + /// * `space` - The space environment name to remove. + /// + /// # Returns + /// + /// `true` if the space was found and removed, `false` otherwise. + /// + /// # Example + /// + /// ```rust + /// let removed = root_mapping.remove_mapping_by_space("prod".to_string()); + /// ``` + pub fn remove_mapping_by_space(&mut self, space: &String) -> bool { + if let Some(ancestors) = self.space_to_ancestor.remove(space) { + for ancestor in ancestors { + self.ancestor_to_space.remove(&ancestor); + } + true + } else { + false + } + } + + /// Retrieves the space environment associated with a given ancestor environment. + /// + /// # Arguments + /// + /// * `ancestor` - The ancestor environment name. + /// + /// # Returns + /// + /// `Some(&String)` if the ancestor exists, `None` otherwise. + /// + /// # Example + /// + /// ```rust + /// if let Some(space) = root_mapping.get_space(&"prod1".to_string()) { + /// println!("prod1 maps to {}", space); + /// } + /// ``` + pub fn get_space(&self, ancestor: &String) -> Option<&String> { + self.ancestor_to_space.get(ancestor) + } + + /// Retrieves all ancestor environments associated with a given space environment. + /// + /// # Arguments + /// + /// * `space` - The space environment name. + /// + /// # Returns + /// + /// `Some(&HashSet)` if the space exists, `None` otherwise. + /// + /// # Example + /// + /// ```rust + /// if let Some(ancestors) = root_mapping.get_ancestors(&"prod".to_string()) { + /// for ancestor in ancestors { + /// println!("prod is mapped by {}", ancestor); + /// } + /// } + /// ``` + pub fn get_ancestors(&self, space: &String) -> &HashSet { + self.space_to_ancestor + .get(space) + .unwrap_or(&DEFAULT_ANCESTOR_SET) + } + + /// Lists all ancestor to space mappings. + /// + /// # Returns + /// + /// A reference to the internal `HashMap` of ancestor to space mappings. + /// + /// # Example + /// + /// ```rust + /// for (ancestor, space) in root_mapping.list_ancestor_to_space() { + /// println!("{} -> {}", ancestor, space); + /// } + /// ``` + pub fn list_ancestor_to_space(&self) -> &HashMap { + &self.ancestor_to_space + } + + /// Lists all space to ancestor mappings. + /// + /// # Returns + /// + /// A reference to the internal `HashMap` of space to ancestor mappings. + /// + /// # Example + /// + /// ```rust + /// for (space, ancestors) in root_mapping.list_space_to_ancestor() { + /// println!("{} -> {:?}", space, ancestors); + /// } + /// ``` + pub fn list_space_to_ancestor(&self) -> &HashMap> { + &self.space_to_ancestor + } + + /// Checks if an ancestor environment exists in the mapping. + /// + /// # Arguments + /// + /// * `ancestor` - The ancestor environment name to check. + /// + /// # Returns + /// + /// `true` if the ancestor exists, `false` otherwise. + /// + /// # Example + /// + /// ```rust + /// if root_mapping.contains_ancestor(&"prod1".to_string()) { + /// println!("prod1 exists in the mapping."); + /// } + /// ``` + pub fn contains_ancestor(&self, ancestor: &String) -> bool { + self.ancestor_to_space.contains_key(ancestor) + } + + /// Checks if a space environment exists in the mapping. + /// + /// # Arguments + /// + /// * `space` - The space environment name to check. + /// + /// # Returns + /// + /// `true` if the space exists, `false` otherwise. + /// + /// # Example + /// + /// ```rust + /// if root_mapping.contains_space(&"prod".to_string()) { + /// println!("prod space exists in the mapping."); + /// } + /// ``` + pub fn contains_space(&self, space: &String) -> bool { + self.space_to_ancestor.contains_key(space) + } + + /// Clears all mappings. + /// + /// # Example + /// + /// ```rust + /// root_mapping.clear(); + /// ``` + pub fn clear(&mut self) { + self.space_to_ancestor.clear(); + self.ancestor_to_space.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_mapping_success() { + let mut mapping = AncestorMapping::new(); + assert!(mapping + .add_mapping("dev".to_string(), "dev".to_string()) + .is_ok()); + assert!(mapping + .add_mapping("test".to_string(), "test".to_string()) + .is_ok()); + assert!(mapping + .add_mapping("prod1".to_string(), "prod".to_string()) + .is_ok()); + assert!(mapping + .add_mapping("prod2".to_string(), "prod".to_string()) + .is_ok()); + + assert_eq!( + mapping.get_space(&"dev".to_string()), + Some(&"dev".to_string()) + ); + assert_eq!( + mapping.get_space(&"prod1".to_string()), + Some(&"prod".to_string()) + ); + assert_eq!( + mapping.get_space(&"prod2".to_string()), + Some(&"prod".to_string()) + ); + assert_eq!( + mapping.get_space(&"test".to_string()), + Some(&"test".to_string()) + ); + + let prod_ancestors = mapping.get_ancestors(&"prod".to_string()); + assert!(prod_ancestors.contains("prod1")); + assert!(prod_ancestors.contains("prod2")); + assert_eq!(prod_ancestors.len(), 2); + } + + #[test] + fn test_add_mapping_duplicate() { + let mut mapping = AncestorMapping::new(); + assert!(mapping + .add_mapping("prod1".to_string(), "prod".to_string()) + .is_ok()); + + // Attempting to add the same ancestor again should fail. + let result = mapping.add_mapping("prod1".to_string(), "staging".to_string()); + assert!(matches!( + result, + Err(RootMappingError::DuplicateAncestor(_)) + )); + if let Err(RootMappingError::DuplicateAncestor(ancestor)) = result { + assert_eq!(ancestor, "prod1"); + } + + // Ensure the original mapping remains unchanged. + assert_eq!( + mapping.get_space(&"prod1".to_string()), + Some(&"prod".to_string()) + ); + } + + #[test] + fn test_replace_mapping_existing() { + let mut mapping = AncestorMapping::new(); + mapping + .add_mapping("prod1".to_string(), "prod".to_string()) + .unwrap(); + mapping + .add_mapping("prod2".to_string(), "prod".to_string()) + .unwrap(); + + // Replace prod1's mapping from "prod" to "staging" + let previous = mapping.replace_mapping("prod1".to_string(), "staging".to_string()); + assert_eq!(previous, Some("prod".to_string())); + + // Verify the new mapping + assert_eq!( + mapping.get_space(&"prod1".to_string()), + Some(&"staging".to_string()) + ); + + // Verify the old space no longer contains prod1 + let prod_ancestors = mapping.get_ancestors(&"prod".to_string()); + assert!(!prod_ancestors.contains("prod1")); + assert!(prod_ancestors.contains("prod2")); + assert_eq!(prod_ancestors.len(), 1); + + // Verify the new space contains prod1 + let staging_ancestors = mapping.get_ancestors(&"staging".to_string()); + assert!(staging_ancestors.contains("prod1")); + assert_eq!(staging_ancestors.len(), 1); + } + + #[test] + fn test_replace_mapping_nonexistent() { + let mut mapping = AncestorMapping::new(); + + // Attempt to replace a non-existent ancestor + let previous = mapping.replace_mapping("nonexistent".to_string(), "staging".to_string()); + assert_eq!(previous, None); + + // Ensure no new mapping was added + assert!(!mapping.contains_ancestor(&"nonexistent".to_string())); + assert!(!mapping.contains_space(&"staging".to_string())); + } + + #[test] + fn test_remove_mapping_by_ancestor() { + let mut mapping = AncestorMapping::new(); + mapping + .add_mapping("prod1".to_string(), "prod".to_string()) + .unwrap(); + mapping + .add_mapping("prod2".to_string(), "prod".to_string()) + .unwrap(); + + assert!(mapping.remove_mapping_by_ancestor(&"prod1".to_string())); + assert!(!mapping.contains_ancestor(&"prod1".to_string())); + let prod_ancestors = mapping.get_ancestors(&"prod".to_string()); + assert!(!prod_ancestors.contains("prod1")); + assert!(prod_ancestors.contains("prod2")); + + // Remove the last ancestor mapping for "prod" + assert!(mapping.remove_mapping_by_ancestor(&"prod2".to_string())); + assert!(!mapping.contains_space(&"prod".to_string())); + } + + #[test] + fn test_remove_mapping_by_space() { + let mut mapping = AncestorMapping::new(); + mapping + .add_mapping("dev".to_string(), "dev".to_string()) + .unwrap(); + mapping + .add_mapping("prod1".to_string(), "prod".to_string()) + .unwrap(); + mapping + .add_mapping("prod2".to_string(), "prod".to_string()) + .unwrap(); + + assert!(mapping.remove_mapping_by_space(&"prod".to_string())); + assert!(!mapping.contains_space(&"prod".to_string())); + assert!(!mapping.contains_ancestor(&"prod1".to_string())); + assert!(!mapping.contains_ancestor(&"prod2".to_string())); + + // Attempt to remove a non-existent space + assert!(!mapping.remove_mapping_by_space(&"staging".to_string())); + } + + #[test] + fn test_clear_mappings() { + let mut mapping = AncestorMapping::new(); + mapping + .add_mapping("dev".to_string(), "dev".to_string()) + .unwrap(); + mapping + .add_mapping("test".to_string(), "test".to_string()) + .unwrap(); + mapping + .add_mapping("prod1".to_string(), "prod".to_string()) + .unwrap(); + + mapping.clear(); + assert!(mapping.list_ancestor_to_space().is_empty()); + assert!(mapping.list_space_to_ancestor().is_empty()); + } + + #[test] + fn test_contains_methods() { + let mut mapping = AncestorMapping::new(); + mapping + .add_mapping("dev".to_string(), "dev".to_string()) + .unwrap(); + mapping + .add_mapping("prod1".to_string(), "prod".to_string()) + .unwrap(); + + assert!(mapping.contains_ancestor(&"dev".to_string())); + assert!(mapping.contains_ancestor(&"prod1".to_string())); + assert!(!mapping.contains_ancestor(&"test".to_string())); + + assert!(mapping.contains_space(&"dev".to_string())); + assert!(mapping.contains_space(&"prod".to_string())); + assert!(!mapping.contains_space(&"test".to_string())); + } +} diff --git a/src/apply_resolved.rs b/src/apply_resolved.rs index e7786a3..a5316ab 100644 --- a/src/apply_resolved.rs +++ b/src/apply_resolved.rs @@ -10,6 +10,7 @@ use serde_json::{Map, Value}; use crate::{ get_environment_value::get_environment_value, map_path::map_path, + merging::merge_values_consume, resolve_spaces::ResolvedSpace, space_graph::{CopyTree, ToCopy}, template_file::template_file, @@ -180,7 +181,20 @@ async fn copy_tocopy_with_env( .with_context(|| format!("Failed to read file: {:?}", file))?; // Apply variable substitution if variables are provided let content = if let Some(variables) = variables { - template_file(&content, variables) + let mut env_value = if let Some(env) = env { + get_environment_value(variables, env).with_context(|| { + format!( + "Failed to get environment value for '{}' in {:?}", + env, variables + ) + })? + } else { + variables.clone() + }; + if let Some(env) = env { + env_value.insert("env".to_string(), Value::String(env.to_string())); + } + template_file(&content, &env_value) .with_context(|| "Failed to apply variable substitution")? } else { content diff --git a/src/lib.rs b/src/lib.rs index 2d9059c..b6dc514 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,22 +6,23 @@ use file_graph::traverse_directory; use resolve_spaces::resolve_spaces; use space_graph::create_space_graph; +mod ancestor_mapping; mod apply_resolved; mod file_graph; +mod get_environment_value; mod map_path; mod merging; +mod parse_jsonc; mod resolve_spaces; mod schemas; mod space_graph; +mod template_file; mod ts_binding; mod write_json_file; -mod template_file; -mod parse_jsonc; -mod get_environment_value; pub async fn generate_weaveconfig(weaveconfig_config_root: &Path) -> Result<()> { let directory = traverse_directory(weaveconfig_config_root).await?; - let space_graph = create_space_graph(directory); + let space_graph = create_space_graph(directory)?; let resolved_spaces = resolve_spaces(space_graph)?; apply_resolved(resolved_spaces, weaveconfig_config_root).await } diff --git a/src/resolve_spaces.rs b/src/resolve_spaces.rs index b05bcd3..6bb730b 100644 --- a/src/resolve_spaces.rs +++ b/src/resolve_spaces.rs @@ -1,4 +1,5 @@ use crate::{ + ancestor_mapping::AncestorMapping, merging::merge_map_consume, space_graph::{CopyTree, GenerateSpace, SpaceGraph}, }; @@ -12,7 +13,7 @@ use std::{ #[derive(Debug, Clone, PartialEq)] pub struct ResolvedSpace { pub variables: Option>, - pub mapping_from_root: HashMap>, + pub root_mapping: AncestorMapping, pub environments: HashSet, pub path: PathBuf, pub files_to_copy: CopyTree, @@ -63,11 +64,38 @@ fn resolve_space( visited.insert(name.to_string()); let mut variables = space.variables.clone(); + + let mut root_mapping = space.parent_mapping.clone(); + if let Some(parent_space) = &space.parent_space { + let parent_space = resolve_parent( + parent_space, + &space.parent_mapping, + &mut variables, + visited, + resolved_spaces, + space_graph, + ) + .with_context(|| format!("Failed to resolve parent for path: {:?}", name))?; + + // Turn the parents root_mapping and this space's parent_mapping into a root_mapping for this space + let mut new_root_mapping = AncestorMapping::new(); + + // For each ancestor in the parent's root mapping + for (ancestor, parent_space_env) in parent_space.root_mapping.list_ancestor_to_space() { + // Look up what this space's environments are for the parent's space environment + if let Some(space_envs) = space.parent_mapping.get_space(parent_space_env) { + // Add mapping from ancestor to this space's environment + new_root_mapping.add_mapping(ancestor.clone(), space_envs.clone())?; + } + } + + root_mapping = new_root_mapping; + } + for dependency in &space.dependencies { resolve_dependency( dependency, - &space.mapping, - &space.environments, + &root_mapping, &mut variables, visited, resolved_spaces, @@ -81,57 +109,12 @@ fn resolve_space( })?; } - let mut mapping_from_root = space.mapping.clone().unwrap_or_default(); - if let Some(parent_space) = &space.parent_space { - let parent_space = resolve_dependency( - parent_space, - &space.mapping, - &space.environments, - &mut variables, - visited, - resolved_spaces, - space_graph, - ) - .with_context(|| format!("Failed to resolve parent for path: {:?}", name))?; - - let mut missing_to_mappings = { - let mut needed_mappings: HashSet<&String> = space.environments.iter().collect(); - needed_mappings.retain(|env| { - mapping_from_root - .iter() - .all(|(_, mappings)| !mappings.contains(&env)) - }); - needed_mappings - }; - - let intersecting_environments: HashSet<&String> = parent_space - .environments - .intersection(&space.environments) - .collect(); - - for env in intersecting_environments.intersection(&missing_to_mappings.clone()) { - mapping_from_root.insert(env.to_string(), vec![env.to_string()]); - missing_to_mappings.remove(env); - } - - if !missing_to_mappings.is_empty() { - return Err(anyhow::anyhow!( - "Missing mappings for environments: {:?}", - missing_to_mappings - )); - } - } - if let Some(variables) = &mut variables { // insert empty object for each environment if not present for env in &space.environments { variables .entry(env.clone()) .or_insert_with(|| Value::Object(Map::new())); - if let Some(Value::Object(obj)) = variables.get_mut(env) { - obj.entry("env".to_string()) - .or_insert(Value::String(env.clone())); - } } } @@ -143,18 +126,63 @@ fn resolve_space( path: space.path.clone(), files_to_copy: space.files_to_copy.clone(), generate: space.generate.clone(), - mapping_from_root, + root_mapping, }, ); Ok(()) } +fn resolve_parent<'a>( + parent_name: &str, + parent_mapping: &AncestorMapping, + this_variables: &mut Option>, + visited: &mut HashSet, + resolved_spaces: &'a mut HashMap, + space_graph: &SpaceGraph, +) -> Result<&'a ResolvedSpace> { + resolve_space(parent_name, visited, resolved_spaces, space_graph) + .with_context(|| format!("Failed to resolve dependency path: {:?}", parent_name))?; + + let resolved_space = resolved_spaces + .get(parent_name) + .with_context(|| format!("Resolved space not found for path: {:?}", parent_name))?; + + let mut to_merge = resolved_space.variables.clone(); + + for dependency_env in &resolved_space.environments { + let space_env = parent_mapping.get_space(dependency_env); + if let Some(space_env) = space_env { + if let Some(ref mut value) = to_merge { + if let Some(moved_value) = value.remove(dependency_env) { + value.insert(space_env.clone(), moved_value.clone()); + } + } + } + } + + if let Some(to_merge) = to_merge { + if let Some(ref mut value) = this_variables { + let value_clone = value.clone(); + let to_merge_clone = to_merge.clone(); + merge_map_consume(value, to_merge).with_context(|| { + format!( + "Failed to merge variables for dependency: {:?}, {:?}, {:?}", + parent_name, value_clone, to_merge_clone + ) + })?; + } else { + *this_variables = Some(to_merge); + } + } + + Ok(&resolved_space) +} + fn resolve_dependency<'a>( dependency_name: &str, - mapping: &Option>>, - environments: &HashSet, - variables: &mut Option>, + root_mapping: &AncestorMapping, + this_variables: &mut Option>, visited: &mut HashSet, resolved_spaces: &'a mut HashMap, space_graph: &SpaceGraph, @@ -168,49 +196,34 @@ fn resolve_dependency<'a>( let mut to_merge = resolved_space.variables.clone(); - for from_env in &resolved_space.environments { - if let Some(mapped_envs) = mapping.as_ref().and_then(|m| m.get(from_env)) { - for to_env in mapped_envs { - if !environments.contains(to_env) { - return Err(anyhow::anyhow!( - "The target environment '{}' is not defined in space. Available environments: {:?}", - to_env, - environments - )); - } - - if let Some(ref mut value) = to_merge { - copy_key(value, from_env, to_env); - } else { - return Err(anyhow::anyhow!( - "No variables present to move from '{}' to '{}'", - from_env, - to_env - )); + if let Some(to_merge) = to_merge.as_mut() { + for dependency_env in &resolved_space.environments { + let rooted_dependency_envs = resolved_space.root_mapping.get_ancestors(dependency_env); + if let Some(moved_value) = to_merge.remove(dependency_env) { + for rooted_dependency_env in rooted_dependency_envs { + let space_env = root_mapping.get_space(rooted_dependency_env); + if let Some(space_env) = space_env { + to_merge.insert(space_env.clone(), moved_value.clone()); + } } } } } if let Some(to_merge) = to_merge { - if let Some(ref mut value) = variables { + if let Some(ref mut value) = this_variables { + let value_clone = value.clone(); + let to_merge_clone = to_merge.clone(); merge_map_consume(value, to_merge).with_context(|| { format!( - "Failed to merge variables for dependency: {:?}", - dependency_name + "Failed to merge variables for dependency: {:?}, {:?}, {:?}", + dependency_name, value_clone, to_merge_clone ) })?; } else { - *variables = Some(to_merge); + *this_variables = Some(to_merge); } } Ok(&resolved_space) } - -fn copy_key(value: &mut Map, from_key: &str, to_key: &str) { - if let Some(current) = value.get(from_key) { - let copied = current.clone(); - value.insert(to_key.to_string(), copied); - } -} diff --git a/src/schemas.rs b/src/schemas.rs index ae2af63..6b7d491 100644 --- a/src/schemas.rs +++ b/src/schemas.rs @@ -1,7 +1,7 @@ -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use serde::Deserialize; +use std::collections::{HashMap, HashSet}; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Deserialize, Debug, Clone, PartialEq)] /// The _space.jsonc file. /// A space describes a folder and its configuration. /// Each space can have multiple environments, each with their own values for the variables in the space. @@ -14,10 +14,8 @@ pub struct SpaceSchema { /// Each element must be a name of another space. /// If not present, the space will not import any dependencies. pub dependencies: Option>, - /// A mapping between the environments from dependencies to the environments in this space. - /// If not present the environments from the dependencies will be used as is. - /// If this field is present, the environments field must be set. - pub mapping: Option>, + /// A mapping from the environments in this space to the environments in the parent space. + pub space_to_parent_mapping: Option>>, /// A list of environments that this space supports. /// An environment describes a particular configuration of the space /// for example, prod, dev, staging, etc. @@ -29,7 +27,7 @@ pub struct SpaceSchema { pub generate: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Deserialize, Debug, Clone, PartialEq)] #[serde(untagged)] pub enum GenerateSchema { /// Toggle full generation on or off. @@ -38,17 +36,8 @@ pub enum GenerateSchema { Generate(GenerateObjectSchema), } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derive(Deserialize, Debug, Clone, PartialEq)] pub struct GenerateObjectSchema { /// Toggle the typescript bindings on or off. pub typescript: bool, } - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -/// A mapping between the environments from dependencies to the environments in this space. -pub struct MappingSchema { - /// The name of the environment in the dependency. - pub from: String, - /// The name of the environment in this space. - pub to: String, -} diff --git a/src/space_graph.rs b/src/space_graph.rs index 4a53e7c..c82454d 100644 --- a/src/space_graph.rs +++ b/src/space_graph.rs @@ -2,7 +2,11 @@ use std::{collections::HashMap, path::PathBuf}; use anyhow::Context; -use crate::{file_graph::Directory, schemas::GenerateSchema}; +use crate::{ + ancestor_mapping::{AncestorMapping, RootMappingError}, + file_graph::Directory, + schemas::GenerateSchema, +}; use std::collections::HashSet; #[derive(Debug, Clone, PartialEq)] @@ -10,7 +14,9 @@ pub struct Space { pub name: String, pub path: PathBuf, pub dependencies: Vec, - pub mapping: Option>>, + // spaces are resolved individually, so these map to their parent, not the root. + // the root mapping is resolved later based on the parent mapping. + pub parent_mapping: AncestorMapping, pub environments: HashSet, pub variables: Option>, pub files_to_copy: CopyTree, @@ -51,37 +57,48 @@ pub struct GenerateSpace { pub type SpaceGraph = HashMap; -pub fn create_space_graph(root_directory: Directory) -> SpaceGraph { +pub fn create_space_graph(root_directory: Directory) -> Result { let mut space_graph = HashMap::new(); - add_to_spaces_graph(root_directory, &mut space_graph, None); + add_to_spaces_graph(root_directory, &mut space_graph, None) + .with_context(|| "Failed to add to spaces graph")?; - space_graph + Ok(space_graph) } fn add_to_spaces_graph( mut dir: Directory, space_graph: &mut SpaceGraph, closest_parent_space: Option, -) { +) -> Result<(), RootMappingError> { let space_name = dir .space .as_ref() .map(|s| s.schema.name.to_string()) .or_else(|| closest_parent_space.clone()); if let Some(space) = dir.space.take() { + let mut mapping = match space.schema.space_to_parent_mapping { + Some(m) => AncestorMapping::from_space_to_ancestors(m)?, + None => AncestorMapping::new(), + }; + let environments = space.schema.environments.unwrap_or_default(); + for environment in &environments { + if !mapping.contains_space(environment) { + mapping + .add_mapping(environment.clone(), environment.clone()) + .expect(&format!( + "Failed to add mapping for environment: {}", + environment + )); + } + } + let space = Space { name: space.schema.name, path: dir.path.clone(), dependencies: space.schema.dependencies.unwrap_or_default(), - mapping: space.schema.mapping.map(|m| { - let mut map = HashMap::new(); - for mapping in m { - map.entry(mapping.from).or_insert(vec![]).push(mapping.to); - } - map - }), - environments: space.schema.environments.unwrap_or_default(), + parent_mapping: mapping, + environments, variables: space.variables, files_to_copy: resolve_files_to_copy(&dir), parent_space: closest_parent_space, @@ -106,8 +123,9 @@ fn add_to_spaces_graph( } for entry in dir.directories { - add_to_spaces_graph(entry, space_graph, space_name.clone()); + add_to_spaces_graph(entry, space_graph, space_name.clone())?; } + Ok(()) } fn resolve_files_to_copy(dir: &Directory) -> CopyTree { diff --git a/src/ts_binding/generate_binding.rs b/src/ts_binding/generate_binding.rs index 4e2dace..f98f732 100644 --- a/src/ts_binding/generate_binding.rs +++ b/src/ts_binding/generate_binding.rs @@ -27,6 +27,12 @@ pub async fn generate_binding( content.push_str("\n"); content.push_str("export type Environments = typeof environments[number];"); + content.push_str("const mappingFromRoot = "); + content.push_str(&format!( + "{} as const;", + serde_json::to_string(&resolved_space.root_mapping.list_ancestor_to_space())? + )); + content.push_str("\n\n// static code starts here, using variant: "); if resolved_space.environments.len() == 0 { content.push_str("zero_env\n\n"); diff --git a/src/ts_binding/global.d.ts b/src/ts_binding/global.d.ts index ccf7ccd..a9481e1 100644 --- a/src/ts_binding/global.d.ts +++ b/src/ts_binding/global.d.ts @@ -1,5 +1,8 @@ declare global { var environments: ["sample_env1", "sample_env2"]; + var mappingFromRoot: { + [key: string]: string; + }; type ConfigType = { sample_env1: { sample_key1: string; diff --git a/src/ts_binding/multi_env.ts b/src/ts_binding/multi_env.ts index 31179be..7c2e9d4 100644 --- a/src/ts_binding/multi_env.ts +++ b/src/ts_binding/multi_env.ts @@ -147,21 +147,23 @@ function read_config_file(): ConfigType | null { } const config = read_config_file(); -export const usedEnvironment = load_env_variable( - __dirname, - "ENV", -) as Environments; -if (!usedEnvironment) { +const env_variable = load_env_variable(__dirname, "ENV"); + +if (!env_variable) { throw new Error( - `ENV is not set, please set ENV variable to one of ${environments.join(", ")}, you can pass this environment variable directly or in a .env file`, + `ENV is not set, please set ENV variable to one of ${Object.keys(mappingFromRoot).join(", ")}, you can pass this environment variable directly or in a .env file`, ); } -if (!environments.includes(usedEnvironment)) { + +export const usedEnvironment = + mappingFromRoot[env_variable as keyof typeof mappingFromRoot]; +if (!usedEnvironment) { throw new Error( - `ENV must be one of ${environments.join(", ")} got ${usedEnvironment}`, + `ENV is set, but is not a known environment, please set ENV variable to one of ${Object.keys(mappingFromRoot).join(", ")}, you can pass this environment variable directly or in a .env file`, ); } + type ObjectKeys = { [K in keyof T]: T[K] extends object ? K : never; }[keyof T];