Skip to main content

relay_knowledge/domain/business/
glossary.rs

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/// Authored repository business glossary schema v1.
15#[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    /// Minimal valid document written by `map init`.
26    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    /// Parses and validates a size-bounded authored glossary.
35    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    /// Enforces v1 cardinality, identity, and field-size bounds.
51    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/// Authored business domain definition.
86#[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/// Lifecycle of one authored business term.
106#[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/// Authored business term and its declared technical mappings.
124#[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/// Declarative calculation semantics. Formula text is not executable in v1.
219#[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/// Authored edge from one business term to a technical target hint.
314#[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;