//! Bounded, deterministic expansion of typed workspace data and visual rule blocks. use crate::{ domain::*, engine, error::{Error, Result, ensure}, }; use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Node { pub id: Uuid, #[serde(deserialize_with = "deserialize_node_kind")] pub kind: String, #[serde(default)] pub key: String, #[serde(default)] pub value: Value, #[serde(default)] pub entries: Vec, } // Normalize legacy stored variables and history recursively without changing node IDs. fn deserialize_node_kind<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> std::result::Result { let kind = String::deserialize(deserializer)?; Ok(if kind == "record" { "dictionary".into() } else { kind }) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Variable { pub id: Uuid, pub name: String, pub node: Node, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Binding { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub items: Vec, #[serde(default)] pub kind: String, #[serde(default)] pub value: Value, #[serde(default)] pub variable: Option, #[serde(default)] pub iteration: Option, #[serde(default)] pub key: Option>, #[serde(default)] pub output: Option, #[serde(default)] pub path: Vec, #[serde(default)] pub prefix: String, #[serde(default)] pub suffix: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Iteration { pub id: Uuid, pub name: String, pub source: Binding, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Predicate { pub left: Binding, pub operation: String, pub right: Binding, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Step { pub delay: Binding, pub recipient: Binding, #[serde(default)] pub user: bool, #[serde(default)] pub condition: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Condition { pub field: String, pub operation: String, pub value: Binding, #[serde(default)] pub negate: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(from = "BlockWire")] pub struct Block { pub id: Uuid, pub kind: String, pub fields: BTreeMap, #[serde(default)] pub steps: Vec, #[serde(default)] pub conditions: Vec, } #[derive(Deserialize)] struct BlockWire { pub id: Uuid, pub kind: String, pub fields: BTreeMap, #[serde(default)] pub steps: Vec, #[serde(default)] pub conditions: Vec, } impl From for Block { fn from(mut value: BlockWire) -> Self { if value.kind == "coverage" { value.kind = "priority".into(); let items = ["primary", "secondary"] .into_iter() .map(|key| value.fields.remove(key).unwrap_or_default()) .collect(); value.fields.insert( "participants".into(), Binding { kind: "list".into(), items, ..Default::default() }, ); } Self { id: value.id, kind: value.kind, fields: value.fields, steps: value.steps, conditions: value.conditions, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Rule { pub id: Uuid, pub name: String, pub enabled: bool, #[serde(default)] pub iterations: Vec, #[serde(default)] pub when: Option, #[serde(default)] pub blocks: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Managed { pub id: Uuid, pub kind: String, pub rule: Uuid, pub block: Uuid, pub item: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Generation { #[serde(default)] pub rules: Vec, #[serde(default)] pub variables: Vec, #[serde(default)] pub route_position: usize, #[serde(default)] pub managed: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Issue { #[serde(default, skip_serializing_if = "Option::is_none")] pub rule_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub block_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub variable_id: Option, #[serde(default)] pub secondary: bool, pub path: String, pub message: String, } #[derive(Debug, Serialize)] pub struct Change { pub kind: String, pub id: Uuid, pub name: String, pub action: String, pub source: String, } pub struct Expansion { pub trace: Vec, pub document: WorkspaceDocument, pub changes: Vec, pub issues: Vec, } pub fn stable_id(parts: &Value) -> Uuid { let digest = Sha256::digest(parts.to_string().as_bytes()); let mut bytes = [0; 16]; bytes.copy_from_slice(&digest[..16]); bytes[6] = (bytes[6] & 15) | 128; bytes[8] = (bytes[8] & 63) | 128; Uuid::from_bytes(bytes) } fn problem(path: impl Into, message: impl Into) -> Issue { Issue { path: path.into(), message: message.into(), ..Issue::default() } } fn validate_node( node: &Node, path: &str, depth: usize, seen: &mut BTreeSet, issues: &mut Vec, ) { if depth > 8 || seen.len() >= 10000 { issues.push(problem( path, crate::i18n::text("err_data_is_limited_to_8_nested_levels_and_10_000_va_fa5d71"), )); return; } if !seen.insert(node.id) { issues.push(problem( path, crate::i18n::text("err_data_entry_ids_must_be_unique_4725be"), )); return; } if matches!(node.kind.as_str(), "list" | "dictionary") { if node.entries.len() > 1000 { issues.push(problem( path, crate::i18n::text("err_a_collection_supports_at_most_1_000_entries"), )); return; } let mut keys = BTreeSet::new(); for (index, entry) in node.entries.iter().enumerate() { let key = if node.kind == "list" { (index + 1).to_string() } else { entry.key.clone() }; if node.kind != "list" && (key.trim().is_empty() || key.len() > 120 || !keys.insert(key.clone())) { issues.push(problem( format!("{path} / {key}"), crate::i18n::text("err_dictionary_keys_must_be_nonempty_and_unique_4e8ce4"), )); } validate_node(entry, &format!("{path} / {key}"), depth + 1, seen, issues); } return; } let valid = match node.kind.as_str() { "text" => node.value.as_str().is_some_and(|s| s.len() <= 2000), "person" => node.value.as_str().is_some_and(|s| { Uuid::parse_str(s).is_ok() || email_address::EmailAddress::is_valid(s) }), "number" | "duration" => node .value .as_f64() .is_some_and(|n| n.is_finite() && (node.kind != "duration" || n >= 0.0)), "boolean" => node.value.is_boolean(), "date" => node .value .as_str() .is_some_and(|s| s.parse::().is_ok()), "time" => node.value.as_str().is_some_and(|s| { NaiveTime::parse_from_str(s, "%H:%M") .or_else(|_| s.parse()) .is_ok() }), _ => false, }; if !valid || !node.entries.is_empty() { issues.push(problem( path, crate::i18n::message( "err_enter_a_valid_p0_value_3b830b", &[("p0", (node.kind).to_string())], ), )); } } pub fn validate_variables(variables: &[Variable]) -> Vec { let mut issues = vec![]; let mut names = BTreeSet::new(); let mut ids = BTreeSet::new(); let mut nodes = BTreeSet::new(); if variables.len() > 100 { return vec![problem( "Variables", crate::i18n::text("err_use_at_most_100_workspace_variables_c7d391"), )]; } for v in variables { let issue_start = issues.len(); let path = crate::i18n::message("err_variable_p0_973058", &[("p0", (v.name).to_string())]); if v.name.trim().is_empty() || v.name.len() > 80 || !names.insert(v.name.trim().to_lowercase()) || !ids.insert(v.id) { issues.push(problem( &path, crate::i18n::text("err_choose_a_unique_variable_name_and_id_ee4fc3"), )); } validate_node(&v.node, &path, 0, &mut nodes, &mut issues); for issue in &mut issues[issue_start..] { issue.variable_id = Some(v.id); } } issues } impl Node { fn json(&self) -> Value { match self.kind.as_str() { "list" => Value::Array(self.entries.iter().map(Node::json).collect()), "dictionary" => Value::Object( self.entries .iter() .map(|e| (e.key.clone(), e.json())) .collect(), ), _ => self.value.clone(), } } fn literal(value: Value) -> Self { let id = stable_id(&json!(["literal", value])); let (kind, entries) = match &value { Value::Array(values) => ( "list", values.iter().map(|v| Self::literal(v.clone())).collect(), ), Value::Object(values) => ( "dictionary", values .iter() .map(|(key, v)| { let mut n = Self::literal(v.clone()); n.key = key.clone(); n }) .collect(), ), _ => ("literal", vec![]), }; Self { id, kind: kind.into(), key: String::new(), value, entries, } } } #[derive(Clone, Default)] struct Context { items: BTreeMap, keys: Vec, labels: Vec, } struct Resolver<'a> { variables: &'a [Variable], context: &'a Context, outputs: &'a BTreeMap, } impl Resolver<'_> { fn eval(&self, b: &Binding, depth: usize) -> Result { ensure( depth <= 16, crate::i18n::text("err_bindings_can_be_nested_at_most_16_levels_b78618"), )?; let var = || { self.variables .iter() .find(|v| Some(v.id) == b.variable) .map(|v| v.node.clone()) .ok_or_else(|| { Error::Validation( crate::i18n::text("err_choose_an_existing_workspace_variable_db6738") .into(), ) }) }; let mut node = match b.kind.as_str() { "literal" => Node::literal(b.value.clone()), "list" => Node::literal(Value::Array( b.items .iter() .map(|item| self.value(item)) .collect::>>()?, )), "variable" => var()?, "item" => self .context .items .get(&b.iteration.unwrap_or_default()) .cloned() .ok_or_else(|| { Error::Validation( crate::i18n::text( "err_choose_an_earlier_iteration_an_item_is_available_4aef3d", ) .into(), ) })?, "key" => Node::literal(json!( self.context .items .get(&b.iteration.unwrap_or_default()) .ok_or_else(|| Error::Validation( crate::i18n::text("err_choose_an_earlier_iteration_3562e3").into() ))? .key )), "lookup" => { let dictionary = var()?; ensure( matches!(dictionary.kind.as_str(), "dictionary"), crate::i18n::text("err_dictionary_lookup_needs_a_dictionary_variable_867cbc"), )?; let key = self .eval( b.key.as_ref().ok_or_else(|| { Error::Validation( crate::i18n::text("err_choose_the_dictionary_lookup_key_66d49d") .into(), ) })?, depth + 1, )? .json(); let key = key.as_str().ok_or_else(|| { Error::Validation( crate::i18n::text("err_dictionary_keys_must_be_text_8bcea8").into(), ) })?; dictionary .entries .iter() .find(|n| n.key == key) .cloned() .ok_or_else(|| { Error::Validation(crate::i18n::message( "err_no_responsible_value_entry_for_key_key_06146d", &[("key", (key).to_string())], )) })? } "output" => Node::literal(json!( self.outputs.get(&b.output.unwrap_or_default()).ok_or_else( || Error::Validation( crate::i18n::text( "err_choose_an_output_block_in_this_rule_or_a_shared_0758e5" ) .into() ) )? )), _ => { return Err(Error::Validation( crate::i18n::text("err_choose_a_value_source_00b9ad").into(), )); } }; for key in &b.path { node = if node.kind == "list" { key.parse::() .ok() .and_then(|i| i.checked_sub(1)) .and_then(|i| node.entries.get(i)) .cloned() } else { node.entries.iter().find(|e| e.key == *key).cloned() } .ok_or_else(|| { Error::Validation(crate::i18n::message( "err_field_or_list_position_key_does_not_exist_4a5e94", &[("key", (key).to_string())], )) })?; } if !b.prefix.is_empty() || !b.suffix.is_empty() { let value = node.json(); let text = value.as_str().ok_or_else(|| { Error::Validation( crate::i18n::text("err_name_prefixes_suffixes_need_a_text_value_96730e").into(), ) })?; node = Node::literal(json!(format!("{}{}{}", b.prefix, text, b.suffix))); } Ok(node) } fn value(&self, b: &Binding) -> Result { Ok(self.eval(b, 0)?.json()) } fn matches(&self, p: &Predicate) -> Result { let left = self.value(&p.left)?; if p.operation == "not_empty" { return Ok(!left.is_null() && left.as_str().is_none_or(|s| !s.is_empty()) && left.as_array().is_none_or(|a| !a.is_empty()) && left.as_object().is_none_or(|o| !o.is_empty())); } let right = self.value(&p.right)?; match p.operation.as_str() { "equals" | "not_equals" => { ensure( std::mem::discriminant(&left) == std::mem::discriminant(&right), crate::i18n::text("err_condition_values_must_have_the_same_type_e949f9"), )?; Ok((left == right) == (p.operation == "equals")) } "contains" => { if let Some(items) = left.as_array() { Ok(items.contains(&right)) } else if let Some((a, b)) = left.as_str().zip(right.as_str()) { Ok(a.contains(b)) } else { Err(Error::Validation( crate::i18n::text( "err_contains_needs_a_list_and_a_value_or_two_text_va_b878ec", ) .into(), )) } } _ => Err(Error::Validation( crate::i18n::text("err_choose_a_supported_condition_f7daba").into(), )), } } } fn value(fields: &BTreeMap, key: &str, resolver: &Resolver<'_>) -> Result { resolver .value(fields.get(key).ok_or_else(|| { Error::Validation(crate::i18n::message( "err_set_key_a7389f", &[("key", (key).to_string())], )) })?) .map_err(|e| Error::Validation(format!("{key}: {e}"))) } fn text(fields: &BTreeMap, key: &str, resolver: &Resolver<'_>) -> Result { value(fields, key, resolver)? .as_str() .map(str::to_owned) .ok_or_else(|| { Error::Validation(crate::i18n::message( "err__key_must_be_text_4fd030", &[("key", (key).to_string())], )) }) } fn number( fields: &BTreeMap, key: &str, resolver: &Resolver<'_>, default: u64, ) -> Result { if !fields.contains_key(key) { return Ok(default); } value(fields, key, resolver)?.as_u64().ok_or_else(|| { Error::Validation(crate::i18n::message( "err__key_must_be_a_nonnegative_whole_number_2fb848", &[("key", (key).to_string())], )) }) } fn recipient( value: &Value, workspace: Uuid, doc: &mut WorkspaceDocument, members: &BTreeSet, ) -> Result { let value = value.as_str().ok_or_else(|| { Error::Validation( crate::i18n::text("err_choose_a_person_or_enter_an_email_address_a24e0a").into(), ) })?; if let Ok(id) = Uuid::parse_str(value) { ensure( members.contains(&id) || doc.person(id).is_some(), crate::i18n::text("err_person_is_not_in_this_workspace_or_team_director_c6d509"), )?; return Ok(id); } let email = value.trim().to_lowercase(); ensure( email_address::EmailAddress::is_valid(&email), crate::i18n::message( "err__value_is_not_an_email_address_a7e20a", &[("value", (value).to_string())], ), )?; if let Some(person) = doc .people .iter() .find(|p| p.email.as_deref() == Some(email.as_str())) { return Ok(person.id); } let id = stable_id(&json!(["generation-contact", workspace, email])); doc.people.push(Person { id, user_id: None, display_name: email.clone(), email: Some(email), country: Some("CY".into()), aliases: vec![], }); Ok(id) } fn id_value(v: Value) -> Result { v.as_str().and_then(|s| s.parse().ok()).ok_or_else(|| { Error::Validation( crate::i18n::text("err_choose_a_schedule_or_policy_reference_f55953").into(), ) }) } fn render( block: &Block, id: Uuid, workspace: Uuid, resolver: &Resolver<'_>, doc: &mut WorkspaceDocument, members: &BTreeSet, ) -> Result<()> { let f = &block.fields; let name = text(f, "name", resolver)?; ensure( !name.trim().is_empty() && name.len() <= 120, crate::i18n::text("err_name_needs_1_120_characters_408c00"), )?; if ["rotation", "priority"].contains(&block.kind.as_str()) { let handover = if f.contains_key("handover") { let time = text(f, "handover", resolver)?; NaiveTime::parse_from_str(&time, "%H:%M") .or_else(|_| time.parse()) .map_err(|_| { Error::Validation(crate::i18n::text("err_handover_use_hh_mm_25d33e").into()) })? } else { default_handover() }; let kind = match block.kind.as_str() { "rotation" | "priority" => { let list = value(f, "participants", resolver)?; let list = list.as_array().ok_or_else(|| { Error::Validation( crate::i18n::text("err_participants_choose_a_list_of_people_emails_d13655") .into(), ) })?; ensure( !list.is_empty() && list.len() <= 100, crate::i18n::text("err_participants_choose_1_100_people_45308a"), )?; let participants = list .iter() .map(|v| recipient(v, workspace, doc, members)) .collect::>>()?; ensure( participants.iter().collect::>().len() == participants.len(), "participants: a person occurs more than once", )?; if block.kind == "priority" { ScheduleKind::Priority { participants, fallback: id_value(value(f, "fallback", resolver)?)?, } } else { let anchor = text(f, "anchor", resolver)?.parse().map_err(|_| { Error::Validation( crate::i18n::text("err_anchor_choose_a_valid_date_497062").into(), ) })?; let days = number(f, "shift_days", resolver, 7)?; ensure( (1..=366).contains(&days), crate::i18n::text("err_shift_days_use_1_366_days_801e00"), )?; ScheduleKind::Rotation { participants, anchor, shift_days: days as u16, } } } _ => unreachable!(), }; doc.schedules.push(Schedule { id, name, handover, kind, }); } else if block.kind == "escalation" { ensure( !block.steps.is_empty() && block.steps.len() <= 20, crate::i18n::text("err_an_escalation_needs_1_20_steps_644fbf"), )?; let mut steps = vec![]; for (i, step) in block.steps.iter().enumerate() { let delay = resolver .value(&step.delay)? .as_u64() .filter(|n| *n <= 10080) .ok_or_else(|| { Error::Validation(crate::i18n::message( "err_step_p0_delay_must_be_0_10080_minutes_b384d0", &[("p0", (i + 1).to_string())], )) })?; let target = resolver.value(&step.recipient)?; steps.push(EscalationStep { delay_minutes: delay as u16, condition: if step.condition == "if-not-closed" { EscalationCondition::IfNotClosed } else { EscalationCondition::IfNotAcked }, recipient: if step.user { Recipient::User { id: recipient(&target, workspace, doc, members)?, } } else { Recipient::Schedule { id: id_value(target)?, } }, }); } let count = number(f, "repeat_count", resolver, 0)?; let wait = number(f, "repeat_wait", resolver, 0)?; ensure( count <= 20 && wait <= 10080, crate::i18n::text("err_escalation_repeat_is_limited_to_20_repeats_10080_9b06f3"), )?; doc.escalations.push(Escalation { id, name, steps, repeat: EscalationRepeat { count: count as u8, wait_minutes: wait as u16, ..Default::default() }, }); } else if block.kind == "route" { let mut conditions = vec![]; for c in &block.conditions { let raw = resolver.value(&c.value)?; conditions.push(RouteCondition { field: c.field.clone(), operation: c.operation.clone(), negate: c.negate, value: raw .as_str() .ok_or_else(|| { Error::Validation( crate::i18n::text("err_routing_condition_value_must_be_text_233f0e") .into(), ) })? .into(), }); } let policy = if f.contains_key("escalation") { Some(id_value(value(f, "escalation", resolver)?)?) } else { None }; let working = if f.contains_key("working") { value(f, "working", resolver)?.as_bool().ok_or_else(|| { Error::Validation( crate::i18n::text("err_working_must_be_true_or_false_10625c").into(), ) })? } else { false }; doc.routes.push(RoutingRule { id, name, order: 0, conditions, escalation_id: policy, window: working.then(WorkingWindow::default), }); } else { return Err(Error::Validation( crate::i18n::text("err_choose_an_output_type_4ab1b5").into(), )); } Ok(()) } #[allow(clippy::too_many_arguments)] pub fn expand( doc: &WorkspaceDocument, workspace: Uuid, variables: &[Variable], rules: &[Rule], route_position: usize, interval: &WorkingWindow, members: &BTreeSet, now: DateTime, ) -> Expansion { let mut trace = vec![]; let mut issues = validate_variables(variables); if rules.len() > 50 { issues.push(problem( "Rules", crate::i18n::text("err_use_at_most_50_generation_rules_abf459"), )); } let mut output = doc.clone(); let old_ids: BTreeSet<_> = doc.generation.managed.iter().map(|m| m.id).collect(); output.schedules.retain(|s| !old_ids.contains(&s.id)); output.escalations.retain(|s| !old_ids.contains(&s.id)); output.routes.retain(|s| !old_ids.contains(&s.id)); let manual_routes = output.routes.clone(); output.routes.clear(); let manual_ids: BTreeSet<_> = output .schedules .iter() .map(|s| s.id) .chain(output.escalations.iter().map(|s| s.id)) .chain(manual_routes.iter().map(|s| s.id)) .collect(); let mut managed = vec![]; let mut block_ids = BTreeSet::new(); let mut rule_ids = BTreeSet::new(); if !issues.is_empty() { return Expansion { trace, document: doc.clone(), changes: vec![], issues, }; } // Singleton outputs are shared across rules; iterated outputs stay local to their row. let shared: BTreeMap<_, _> = rules .iter() .filter(|r| r.enabled && r.iterations.is_empty()) .flat_map(|r| { r.blocks.iter().map(move |b| { ( b.id, stable_id(&json!(["generated", workspace, r.id, [], b.id])), ) }) }) .collect(); for rule in rules { trace.push(json!({"rule":rule.id,"enabled":rule.enabled,"stages":[],"rows":[]})); let issue_start = issues.len(); let path = crate::i18n::message("err_rule_p0_eb6431", &[("p0", (rule.name).to_string())]); if !rule_ids.insert(rule.id) || rule.name.trim().is_empty() || rule.name.len() > 120 || rule.iterations.len() > 4 || rule.blocks.len() > 20 { issues.push(problem( &path, crate::i18n::text("err_use_a_unique_rule_id_name_at_most_4_iterations_a_c413b3"), )); continue; } if !rule.enabled { continue; } let mut contexts = vec![Context::default()]; let mut loop_ids = BTreeSet::new(); for iteration in &rule.iterations { if !loop_ids.insert(iteration.id) { issues.push(problem( &path, crate::i18n::text("err_iteration_ids_must_be_unique_bfb5cc"), )); contexts.clear(); break; } let mut next = vec![]; for context in contexts { let empty = BTreeMap::new(); let resolver = Resolver { variables, context: &context, outputs: &empty, }; match resolver.eval(&iteration.source, 0) { Ok(node) if matches!(node.kind.as_str(), "list" | "dictionary") => { let mut values = BTreeSet::new(); for entry in node.entries { let label = if node.kind == "list" { entry .json() .as_str() .map(str::to_owned) .unwrap_or_else(|| entry.id.to_string()) } else { entry.key.clone() }; if !values.insert(label.clone()) { issues.push(problem( &path, crate::i18n::message( "err_iteration_p0_contains_duplicate_value_label_1743fe", &[ ("p0", (iteration.name).to_string()), ("label", (label).to_string()), ], ), )); continue; } let mut child = context.clone(); child.keys.push(entry.id); child.labels.push(label.clone()); let mut entry = entry; if node.kind == "list" { entry.key = label; } child.items.insert(iteration.id, entry); next.push(child); if next.len() > 500 { break; } } } Ok(_) => issues.push(problem( format!("{path} / {}", iteration.name), crate::i18n::text("err_for_each_needs_a_list_or_dictionary_c87f69"), )), Err(e) => issues.push(problem( format!( "{path} / {} / {}", iteration.name, context.labels.join(" / ") ), e.to_string(), )), } if next.len() > 500 { break; } } if next.len() > 500 { issues.push(problem( &path, crate::i18n::text( "err_expansion_is_limited_to_500_iteration_combinatio_81a6ef", ), )); contexts = vec![]; break; } trace.last_mut().unwrap()["stages"] .as_array_mut() .unwrap() .push(json!({"iteration":iteration.id,"count":next.len()})); contexts = next; } for block in &rule.blocks { if !block_ids.insert(block.id) { issues.push(problem( &path, crate::i18n::text("err_output_block_ids_must_be_unique_across_rules_1efed6"), )); } } for context in contexts { let mut ids = shared.clone(); ids.extend(rule.blocks.iter().map(|b| { ( b.id, stable_id(&json!([ "generated", workspace, rule.id, context.keys, b.id ])), ) })); let resolver = Resolver { variables, context: &context, outputs: &ids, }; let row_path = if context.labels.is_empty() { path.clone() } else { format!("{path} / {}", context.labels.join(" / ")) }; let mut row_trace = json!({"labels":context.labels,"inputs":context.items.iter().map(|(id,n)|(id.to_string(),n.json())).collect::>(),"status":"included","resources":[],"blocks":[]}); let managed_start = managed.len(); let row_issue_start = issues.len(); if let Some(predicate) = &rule.when { row_trace["condition"] = json!({"left":resolver.eval(&predicate.left,0).ok().map(|n|n.json()),"operation":predicate.operation,"right":resolver.eval(&predicate.right,0).ok().map(|n|n.json())}); match resolver.matches(predicate) { Ok(true) => {} Ok(false) => { row_trace["status"] = json!("filtered"); trace.last_mut().unwrap()["rows"] .as_array_mut() .unwrap() .push(row_trace); continue; } Err(e) => { issues.push(problem(&row_path, e.to_string())); row_trace["status"] = json!("error"); trace.last_mut().unwrap()["rows"] .as_array_mut() .unwrap() .push(row_trace); continue; } } } for (index, block) in rule.blocks.iter().enumerate() { let id = ids[&block.id]; let resolved = |binding: &Binding| { resolver .eval(binding, 0) .map(|n| n.json()) .unwrap_or(Value::Null) }; row_trace["blocks"].as_array_mut().unwrap().push(json!({ "block":block.id, "fields":block.fields.iter().map(|(key,b)|(key.clone(),resolved(b))).collect::>(), "steps":block.steps.iter().map(|s|json!({"delay":resolved(&s.delay),"recipient":resolved(&s.recipient),"condition":s.condition})).collect::>(), "conditions":block.conditions.iter().map(|c|json!({"field":c.field,"operation":c.operation,"value":resolved(&c.value),"negate":c.negate})).collect::>() })); if manual_ids.contains(&id) { issues.push(problem( &row_path, crate::i18n::text( "err_generated_identity_collides_with_a_manual_resour_4403f1", ), )); continue; } if managed.len() >= 900 { issues.push(problem( &path, crate::i18n::text("err_expansion_exceeds_the_team_resource_budget_5669f8"), )); break; } match render(block, id, workspace, &resolver, &mut output, members) { Ok(()) => managed.push(Managed { id, kind: block.kind.clone(), rule: rule.id, block: block.id, item: context.labels.join(" / "), }), Err(e) => { let mut issue = problem( format!("{row_path} / {} block {}", block.kind, index + 1), e.to_string(), ); issue.rule_id = Some(rule.id); issue.block_id = Some(block.id); issues.push(issue); } } } if issues.len() > row_issue_start { row_trace["status"] = json!("error"); } row_trace["resources"] = json!( managed[managed_start..] .iter() .map(|m| m.id) .collect::>() ); trace.last_mut().unwrap()["rows"] .as_array_mut() .unwrap() .push(row_trace); } for issue in &mut issues[issue_start..] { issue.rule_id = Some(rule.id); } } let current_ids: BTreeSet<_> = managed.iter().map(|m| m.id).collect(); let removed: BTreeSet<_> = old_ids.difference(¤t_ids).copied().collect(); for request in &mut output.requests { if !request .legs .iter() .any(|l| removed.contains(&l.schedule_id)) { continue; } if request.status == RequestStatus::Applied && request .effective_legs() .iter() .any(|l| removed.contains(&l.schedule_id) && l.end > now) { issues.push(problem( "Removal", crate::i18n::text("err_a_generated_schedule_has_current_future_approved_0abed7"), )); } if request.status == RequestStatus::Pending { request.status = RequestStatus::Cancelled; } } let mut generated_routes = std::mem::take(&mut output.routes); for route in &mut generated_routes { if route.window.is_some() { route.window = Some(interval.clone()); } } let mut routes = manual_routes; routes.sort_by_key(|r| r.order); let position = route_position.min(routes.len()); routes.splice(position..position, generated_routes); for (i, r) in routes.iter_mut().enumerate() { r.order = i as u16; } output.routes = routes; output.generation = Generation { rules: rules.to_vec(), variables: variables.to_vec(), route_position: position, managed, }; if let Err(e) = output.validate(members) { let mut issue = problem( crate::i18n::text("err_generated_configuration_61fd64"), e.to_string(), ); issue.secondary = !issues.is_empty(); issues.push(issue); } if issues.is_empty() && let Err(e) = engine::timeline( &output, now, now + chrono::Duration::days(i64::from(output.horizon_days)), ) { issues.push(problem( crate::i18n::text("err_generated_calendar_5e4edc"), e.to_string(), )); } let before = resources(doc); let after = resources(&output); let mut changes = vec![]; for m in &output.generation.managed { let Some((name, value)) = after.get(&m.id) else { continue; }; let order_changed = m.kind == "route" && doc.routes.iter().find(|r| r.id == m.id).map(|r| r.order) != output.routes.iter().find(|r| r.id == m.id).map(|r| r.order); let action = match before.get(&m.id) { None => "create", Some((_, old)) if old == value && !order_changed => "unchanged", _ => "update", }; changes.push(Change { kind: m.kind.clone(), id: m.id, name: name.clone(), action: action.into(), source: m.item.clone(), }); } for m in &doc.generation.managed { if removed.contains(&m.id) { changes.push(Change { kind: m.kind.clone(), id: m.id, name: before .get(&m.id) .map(|(n, _)| n.clone()) .unwrap_or_default(), action: "remove".into(), source: m.item.clone(), }); } } Expansion { trace, document: output, changes, issues, } } fn resources(doc: &WorkspaceDocument) -> BTreeMap { let mut map = BTreeMap::new(); for s in &doc.schedules { map.insert( s.id, ( s.name.clone(), serde_json::to_value(s).expect("serializable schedule"), ), ); } for s in &doc.escalations { map.insert( s.id, ( s.name.clone(), serde_json::to_value(s).expect("serializable policy"), ), ); } for s in &doc.routes { let mut value = serde_json::to_value(s).expect("serializable route"); value.as_object_mut().unwrap().remove("order"); map.insert(s.id, (s.name.clone(), value)); } map } pub fn validate_manual_edit( before: &WorkspaceDocument, after: &mut WorkspaceDocument, ) -> Result<()> { ensure( serde_json::to_value(&before.generation)? == serde_json::to_value(&after.generation)?, crate::i18n::text("err_edit_generation_sources_in_variables_rules_8ec0b0"), )?; let old = resources(before); let new = resources(after); ensure( before .generation .managed .iter() .all(|m| old.get(&m.id) == new.get(&m.id)), crate::i18n::text("err_this_resource_is_generated_edit_its_source_in_va_b45498"), )?; validate_route_block(after) } pub fn validate_route_block(doc: &mut WorkspaceDocument) -> Result<()> { let expected: Vec<_> = doc .generation .managed .iter() .filter(|m| m.kind == "route") .map(|m| m.id) .collect(); if expected.is_empty() { return Ok(()); } let mut sorted = doc.routes.clone(); sorted.sort_by_key(|r| r.order); let start = sorted .iter() .position(|r| r.id == expected[0]) .ok_or_else(|| { Error::Validation( crate::i18n::text("err_a_generated_routing_rule_is_missing_df7dc4").into(), ) })?; ensure( sorted .iter() .skip(start) .take(expected.len()) .map(|r| r.id) .collect::>() == expected, crate::i18n::text("err_generated_routing_rules_move_as_one_block_set_th_d09425"), )?; doc.generation.route_position = start; Ok(()) }