309 lines
9.9 KiB
Rust
309 lines
9.9 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ConfigLayer {
|
|
Global = 0,
|
|
Team = 1,
|
|
Project = 2,
|
|
Env = 3,
|
|
Task = 4,
|
|
}
|
|
|
|
impl ConfigLayer {
|
|
pub fn priority(&self) -> u8 {
|
|
*self as u8
|
|
}
|
|
|
|
pub fn label(&self) -> &'static str {
|
|
match self {
|
|
ConfigLayer::Global => "global",
|
|
ConfigLayer::Team => "team",
|
|
ConfigLayer::Project => "project",
|
|
ConfigLayer::Env => "env",
|
|
ConfigLayer::Task => "task",
|
|
}
|
|
}
|
|
|
|
pub fn all() -> &'static [ConfigLayer] {
|
|
&[ConfigLayer::Global, ConfigLayer::Team, ConfigLayer::Project, ConfigLayer::Env, ConfigLayer::Task]
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LayerConfig {
|
|
pub layer: ConfigLayer,
|
|
pub name: String,
|
|
|
|
#[serde(default)]
|
|
pub description: String,
|
|
|
|
#[serde(default)]
|
|
pub values: HashMap<String, serde_json::Value>,
|
|
|
|
#[serde(default)]
|
|
pub tags: HashMap<String, String>,
|
|
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
|
|
#[serde(default = "default_priority")]
|
|
pub priority_override: Option<u8>,
|
|
|
|
#[serde(default)]
|
|
pub metadata: HashMap<String, String>,
|
|
}
|
|
|
|
impl Default for LayerConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
layer: ConfigLayer::Global,
|
|
name: String::new(),
|
|
description: String::new(),
|
|
values: HashMap::new(),
|
|
tags: HashMap::new(),
|
|
enabled: true,
|
|
priority_override: None,
|
|
metadata: HashMap::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_priority() -> Option<u8> {
|
|
None
|
|
}
|
|
|
|
impl LayerConfig {
|
|
pub fn effective_priority(&self) -> u8 {
|
|
self.priority_override.unwrap_or_else(|| self.layer.priority())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MergedConfig {
|
|
pub values: HashMap<String, serde_json::Value>,
|
|
pub tags: HashMap<String, String>,
|
|
pub provenance: HashMap<String, ConfigLayer>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ConfigTree {
|
|
pub layers: Vec<LayerConfig>,
|
|
}
|
|
|
|
impl Default for ConfigTree {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl ConfigTree {
|
|
pub fn new() -> Self {
|
|
Self { layers: Vec::new() }
|
|
}
|
|
|
|
pub fn add_layer(&mut self, config: LayerConfig) {
|
|
self.layers.push(config);
|
|
}
|
|
|
|
pub fn merge(&self) -> Result<MergedConfig, String> {
|
|
let mut sorted: Vec<&LayerConfig> = self.layers.iter().filter(|l| l.enabled).collect();
|
|
sorted.sort_by_key(|l| l.effective_priority());
|
|
|
|
let mut values: HashMap<String, serde_json::Value> = HashMap::new();
|
|
let mut tags: HashMap<String, String> = HashMap::new();
|
|
let mut provenance: HashMap<String, ConfigLayer> = HashMap::new();
|
|
|
|
for layer_config in &sorted {
|
|
for (k, v) in &layer_config.values {
|
|
values.insert(k.clone(), v.clone());
|
|
provenance.insert(k.clone(), layer_config.layer);
|
|
}
|
|
for (k, v) in &layer_config.tags {
|
|
tags.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
|
|
Ok(MergedConfig { values, tags, provenance })
|
|
}
|
|
|
|
pub fn merge_with_guard(&self, tag_guard: &crate::config::tag::TagGuard) -> Result<MergedConfig, String> {
|
|
let merged = self.merge()?;
|
|
let tags: Vec<crate::config::tag::BusinessTag> = merged
|
|
.tags
|
|
.iter()
|
|
.map(|(k, v)| crate::config::tag::BusinessTag {
|
|
key: k.clone(),
|
|
value: v.clone(),
|
|
description: String::new(),
|
|
immutable: false,
|
|
})
|
|
.collect();
|
|
let base_tags: HashMap<String, String> = HashMap::new();
|
|
let result = tag_guard.validate_and_collect(&tags, &base_tags);
|
|
if tag_guard.validator.has_blocking_violations(&result) {
|
|
let (blocked, violations, _total) = tag_guard.blocking_summary();
|
|
return Err(format!("Tag guard blocked config merge: {blocked} tags blocked, {violations} violations"));
|
|
}
|
|
Ok(merged)
|
|
}
|
|
|
|
pub fn validate(&self) -> Result<(), Vec<String>> {
|
|
let mut errors = Vec::new();
|
|
let mut seen_keys: HashMap<&str, usize> = HashMap::new();
|
|
|
|
for (i, layer) in self.layers.iter().enumerate() {
|
|
if layer.name.is_empty() {
|
|
errors.push(format!("Layer at index {i} has empty name"));
|
|
}
|
|
if layer.values.is_empty() && layer.tags.is_empty() {
|
|
errors.push(format!("Layer '{}' ({}) has no values or tags", layer.name, layer.layer.label()));
|
|
}
|
|
for key in layer.values.keys() {
|
|
if let Some(prev) = seen_keys.get(key.as_str()) {
|
|
errors.push(format!("Key '{}' defined in multiple layers (index {prev} and {i})", key));
|
|
}
|
|
seen_keys.entry(key.as_str()).or_insert(i);
|
|
}
|
|
}
|
|
|
|
if errors.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(errors)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_layer(layer: ConfigLayer, name: &str, key: &str, value: &str) -> LayerConfig {
|
|
let mut values = HashMap::new();
|
|
values.insert(key.to_string(), serde_json::Value::String(value.to_string()));
|
|
LayerConfig { layer, name: name.to_string(), values, enabled: true, ..Default::default() }
|
|
}
|
|
|
|
#[test]
|
|
fn test_layer_priority_order() {
|
|
assert!(ConfigLayer::Global.priority() < ConfigLayer::Team.priority());
|
|
assert!(ConfigLayer::Team.priority() < ConfigLayer::Project.priority());
|
|
assert!(ConfigLayer::Project.priority() < ConfigLayer::Env.priority());
|
|
assert!(ConfigLayer::Env.priority() < ConfigLayer::Task.priority());
|
|
}
|
|
|
|
#[test]
|
|
fn test_layer_label() {
|
|
assert_eq!(ConfigLayer::Global.label(), "global");
|
|
assert_eq!(ConfigLayer::Task.label(), "task");
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_tree_merge_basic() {
|
|
let mut tree = ConfigTree::new();
|
|
tree.add_layer(make_layer(ConfigLayer::Global, "global-cfg", "host", "db-global"));
|
|
tree.add_layer(make_layer(ConfigLayer::Project, "proj-cfg", "host", "db-proj"));
|
|
|
|
let merged = tree.merge().unwrap();
|
|
assert_eq!(merged.values.get("host").unwrap(), &serde_json::Value::String("db-proj".to_string()));
|
|
assert_eq!(*merged.provenance.get("host").unwrap(), ConfigLayer::Project);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_tree_merge_lower_priority_does_not_override() {
|
|
let mut tree = ConfigTree::new();
|
|
tree.add_layer(make_layer(ConfigLayer::Project, "proj-cfg", "port", "5432"));
|
|
tree.add_layer(make_layer(ConfigLayer::Global, "global-cfg", "port", "3306"));
|
|
|
|
let merged = tree.merge().unwrap();
|
|
assert_eq!(merged.values.get("port").unwrap(), &serde_json::Value::String("5432".to_string()));
|
|
assert_eq!(*merged.provenance.get("port").unwrap(), ConfigLayer::Project);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_tree_merge_disabled_layer_ignored() {
|
|
let mut tree = ConfigTree::new();
|
|
let mut global = make_layer(ConfigLayer::Global, "global-cfg", "host", "db-global");
|
|
global.enabled = false;
|
|
tree.add_layer(global);
|
|
tree.add_layer(make_layer(ConfigLayer::Task, "task-cfg", "host", "db-task"));
|
|
|
|
let merged = tree.merge().unwrap();
|
|
assert_eq!(merged.values.get("host").unwrap(), &serde_json::Value::String("db-task".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_tree_merge_distinct_keys() {
|
|
let mut tree = ConfigTree::new();
|
|
tree.add_layer(make_layer(ConfigLayer::Global, "g", "host", "g-host"));
|
|
tree.add_layer(make_layer(ConfigLayer::Team, "t", "port", "t-port"));
|
|
|
|
let merged = tree.merge().unwrap();
|
|
assert_eq!(merged.values.len(), 2);
|
|
assert!(merged.values.contains_key("host"));
|
|
assert!(merged.values.contains_key("port"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_priority_override() {
|
|
let mut tree = ConfigTree::new();
|
|
let mut global = make_layer(ConfigLayer::Global, "override-cfg", "key", "val");
|
|
global.priority_override = Some(255);
|
|
tree.add_layer(global);
|
|
tree.add_layer(make_layer(ConfigLayer::Task, "task-cfg", "key", "task-val"));
|
|
|
|
let merged = tree.merge().unwrap();
|
|
assert_eq!(merged.values.get("key").unwrap(), &serde_json::Value::String("val".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_empty_name() {
|
|
let tree = ConfigTree {
|
|
layers: vec![LayerConfig {
|
|
layer: ConfigLayer::Global,
|
|
name: String::new(),
|
|
values: HashMap::from([("k".to_string(), serde_json::Value::String("v".to_string()))]),
|
|
..Default::default()
|
|
}],
|
|
};
|
|
assert!(tree.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_empty_values_and_tags() {
|
|
let layer = LayerConfig {
|
|
layer: ConfigLayer::Global,
|
|
name: "empty".to_string(),
|
|
values: HashMap::new(),
|
|
tags: HashMap::new(),
|
|
..Default::default()
|
|
};
|
|
let tree = ConfigTree { layers: vec![layer] };
|
|
let errs = tree.validate().unwrap_err();
|
|
assert!(errs.iter().any(|e| e.contains("empty")));
|
|
}
|
|
|
|
#[test]
|
|
fn test_effective_priority_default() {
|
|
let l = make_layer(ConfigLayer::Env, "env", "k", "v");
|
|
assert_eq!(l.effective_priority(), ConfigLayer::Env.priority());
|
|
}
|
|
|
|
#[test]
|
|
fn test_effective_priority_override() {
|
|
let mut l = make_layer(ConfigLayer::Global, "g", "k", "v");
|
|
l.priority_override = Some(100);
|
|
assert_eq!(l.effective_priority(), 100);
|
|
}
|
|
|
|
#[test]
|
|
fn test_all_layers() {
|
|
let all = ConfigLayer::all();
|
|
assert_eq!(all.len(), 5);
|
|
assert_eq!(all[0], ConfigLayer::Global);
|
|
assert_eq!(all[4], ConfigLayer::Task);
|
|
}
|
|
}
|