1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::domain::DomainError;
6
7pub const BUSINESS_GLOSSARY_SCHEMA_VERSION: u16 = 1;
8pub const BUSINESS_GLOSSARY_MAX_BYTES: usize = 4 * 1024 * 1024;
9pub const BUSINESS_GLOSSARY_MAX_DOMAINS: usize = 256;
10pub const BUSINESS_GLOSSARY_MAX_TERMS: usize = 10_000;
11pub const BUSINESS_TERM_MAX_ALIASES: usize = 32;
12pub const BUSINESS_TERM_MAX_MAPPINGS: usize = 64;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct BusinessGlossary {
17 pub schema_version: u16,
18 #[serde(default)]
19 pub domains: Vec<BusinessDomainDefinition>,
20 #[serde(default)]
21 pub terms: Vec<BusinessTermDefinition>,
22}
23
24impl BusinessGlossary {
25 pub const fn empty_v1() -> Self {
27 Self {
28 schema_version: BUSINESS_GLOSSARY_SCHEMA_VERSION,
29 domains: Vec::new(),
30 terms: Vec::new(),
31 }
32 }
33
34 pub fn parse(content: &[u8]) -> Result<Self, DomainError> {
36 if content.len() > BUSINESS_GLOSSARY_MAX_BYTES {
37 return Err(DomainError::invalid(
38 "business_glossary",
39 "must be 4194304 bytes or less",
40 ));
41 }
42 let text = std::str::from_utf8(content)
43 .map_err(|_| DomainError::invalid("business_glossary", "must be valid UTF-8"))?;
44 let glossary = serde_norway::from_str::<Self>(text)
45 .map_err(|error| DomainError::invalid("business_glossary", error.to_string()))?;
46 glossary.validate()?;
47 Ok(glossary)
48 }
49
50 pub fn validate(&self) -> Result<(), DomainError> {
52 if self.schema_version != BUSINESS_GLOSSARY_SCHEMA_VERSION {
53 return Err(DomainError::invalid("schema_version", "must be 1"));
54 }
55 enforce_count("domains", self.domains.len(), BUSINESS_GLOSSARY_MAX_DOMAINS)?;
56 enforce_count("terms", self.terms.len(), BUSINESS_GLOSSARY_MAX_TERMS)?;
57
58 let mut domain_ids = HashSet::new();
59 for domain in &self.domains {
60 domain.validate()?;
61 if !domain_ids.insert(domain.id.as_str()) {
62 return Err(DomainError::invalid("domains", "domain ids must be unique"));
63 }
64 }
65 let mut term_ids = HashSet::new();
66 for term in &self.terms {
67 term.validate()?;
68 if !domain_ids.contains(term.domain.as_str()) {
69 return Err(DomainError::invalid(
70 "terms",
71 format!("term '{}' references unknown domain", term.id),
72 ));
73 }
74 if !term_ids.insert((term.domain.as_str(), term.id.as_str())) {
75 return Err(DomainError::invalid(
76 "terms",
77 "term ids must be unique within a domain",
78 ));
79 }
80 }
81 Ok(())
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct BusinessDomainDefinition {
88 pub id: String,
89 pub name: String,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub description: Option<String>,
92}
93
94impl BusinessDomainDefinition {
95 fn validate(&self) -> Result<(), DomainError> {
96 bounded_text("domain.id", &self.id, 128)?;
97 bounded_text("domain.name", &self.name, 1_024)?;
98 if let Some(description) = &self.description {
99 bounded_text("domain.description", description, 32 * 1_024)?;
100 }
101 Ok(())
102 }
103}
104
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum BusinessTermStatus {
109 #[default]
110 Active,
111 Deprecated,
112}
113
114impl BusinessTermStatus {
115 pub const fn as_str(self) -> &'static str {
116 match self {
117 Self::Active => "active",
118 Self::Deprecated => "deprecated",
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct BusinessTermDefinition {
126 pub id: String,
127 pub domain: String,
128 pub canonical_name: String,
129 pub definition: String,
130 #[serde(default = "default_language")]
131 pub language: String,
132 #[serde(default)]
133 pub status: BusinessTermStatus,
134 #[serde(default)]
135 pub aliases: Vec<BusinessAlias>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub semantics: Option<BusinessSemantics>,
138 #[serde(default)]
139 pub mappings: Vec<BusinessTechnicalMappingDefinition>,
140}
141
142impl BusinessTermDefinition {
143 fn validate(&self) -> Result<(), DomainError> {
144 bounded_text("term.id", &self.id, 128)?;
145 bounded_text("term.domain", &self.domain, 128)?;
146 bounded_text("term.canonical_name", &self.canonical_name, 1_024)?;
147 bounded_text("term.definition", &self.definition, 32 * 1_024)?;
148 bounded_text("term.language", &self.language, 128)?;
149 enforce_count(
150 "term.aliases",
151 self.aliases.len(),
152 BUSINESS_TERM_MAX_ALIASES,
153 )?;
154 enforce_count(
155 "term.mappings",
156 self.mappings.len(),
157 BUSINESS_TERM_MAX_MAPPINGS,
158 )?;
159 let mut aliases = HashSet::new();
160 for alias in &self.aliases {
161 alias.validate()?;
162 let folded = alias.value.to_lowercase();
163 if !aliases.insert(folded) {
164 return Err(DomainError::invalid(
165 "term.aliases",
166 "alias values must be unique without case collisions",
167 ));
168 }
169 }
170 if let Some(semantics) = &self.semantics {
171 semantics.validate()?;
172 }
173 for mapping in &self.mappings {
174 mapping.validate()?;
175 }
176 Ok(())
177 }
178}
179
180fn default_language() -> String {
181 "und".to_owned()
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum BusinessAliasKind {
187 Synonym,
188 Abbreviation,
189}
190
191impl BusinessAliasKind {
192 pub const fn as_str(self) -> &'static str {
193 match self {
194 Self::Synonym => "synonym",
195 Self::Abbreviation => "abbreviation",
196 }
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct BusinessAlias {
202 pub value: String,
203 pub kind: BusinessAliasKind,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub language: Option<String>,
206}
207
208impl BusinessAlias {
209 fn validate(&self) -> Result<(), DomainError> {
210 bounded_text("alias.value", &self.value, 1_024)?;
211 if let Some(language) = &self.language {
212 bounded_text("alias.language", language, 128)?;
213 }
214 Ok(())
215 }
216}
217
218#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
220pub struct BusinessSemantics {
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub formula: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub aggregation: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub unit: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub grain: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub time_basis: Option<String>,
231 #[serde(default)]
232 pub includes: Vec<String>,
233 #[serde(default)]
234 pub excludes: Vec<String>,
235}
236
237impl BusinessSemantics {
238 fn validate(&self) -> Result<(), DomainError> {
239 if let Some(formula) = &self.formula {
240 bounded_text("semantics.formula", formula, 32 * 1_024)?;
241 }
242 for (field, value) in [
243 ("semantics.aggregation", self.aggregation.as_deref()),
244 ("semantics.unit", self.unit.as_deref()),
245 ("semantics.grain", self.grain.as_deref()),
246 ("semantics.time_basis", self.time_basis.as_deref()),
247 ] {
248 if let Some(value) = value {
249 bounded_text(field, value, 1_024)?;
250 }
251 }
252 enforce_count("semantics.includes", self.includes.len(), 256)?;
253 enforce_count("semantics.excludes", self.excludes.len(), 256)?;
254 for value in self.includes.iter().chain(&self.excludes) {
255 bounded_text("semantics.boundary", value, 1_024)?;
256 }
257 Ok(())
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum BusinessMappingRelation {
264 RepresentedBy,
265 CalculatedFrom,
266}
267
268impl BusinessMappingRelation {
269 pub const fn as_str(self) -> &'static str {
270 match self {
271 Self::RepresentedBy => "represented_by",
272 Self::CalculatedFrom => "calculated_from",
273 }
274 }
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum TechnicalTargetKind {
280 File,
281 Symbol,
282 ConfigKey,
283 Api,
284 SoftwareComponent,
285 BuildTarget,
286 Iac,
287 DesignElement,
288 DatabaseTable,
289 DatabaseColumn,
290 Metric,
291 External,
292}
293
294impl TechnicalTargetKind {
295 pub const fn as_str(self) -> &'static str {
296 match self {
297 Self::File => "file",
298 Self::Symbol => "symbol",
299 Self::ConfigKey => "config_key",
300 Self::Api => "api",
301 Self::SoftwareComponent => "software_component",
302 Self::BuildTarget => "build_target",
303 Self::Iac => "iac",
304 Self::DesignElement => "design_element",
305 Self::DatabaseTable => "database_table",
306 Self::DatabaseColumn => "database_column",
307 Self::Metric => "metric",
308 Self::External => "external",
309 }
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct BusinessTechnicalMappingDefinition {
316 pub relation: BusinessMappingRelation,
317 pub target_kind: TechnicalTargetKind,
318 pub target: String,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub path: Option<String>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub source_scope: Option<String>,
323}
324
325impl BusinessTechnicalMappingDefinition {
326 fn validate(&self) -> Result<(), DomainError> {
327 bounded_text("mapping.target", &self.target, 1_024)?;
328 if let Some(path) = &self.path {
329 bounded_text("mapping.path", path, 1_024)?;
330 }
331 if let Some(source_scope) = &self.source_scope {
332 bounded_text("mapping.source_scope", source_scope, 1_024)?;
333 }
334 Ok(())
335 }
336}
337
338fn bounded_text(field: &'static str, value: &str, max_bytes: usize) -> Result<(), DomainError> {
339 if value.trim().is_empty() {
340 return Err(DomainError::invalid(field, "must not be empty"));
341 }
342 if value.len() > max_bytes {
343 return Err(DomainError::invalid(
344 field,
345 format!("must be {max_bytes} bytes or less"),
346 ));
347 }
348 if value.contains('\0') {
349 return Err(DomainError::invalid(field, "must not contain NUL bytes"));
350 }
351 Ok(())
352}
353
354fn enforce_count(field: &'static str, count: usize, max: usize) -> Result<(), DomainError> {
355 if count > max {
356 return Err(DomainError::invalid(
357 field,
358 format!("must contain {max} or fewer entries"),
359 ));
360 }
361 Ok(())
362}
363
364#[cfg(test)]
365#[path = "glossary_tests.rs"]
366mod tests;