1use std::collections::BTreeSet;
4
5use serde::Serialize;
6use type_bridge_contract::id::FunctionId;
7use type_bridge_contract::schema::{
8 AnnotationFact, AnnotationKindId, AnnotationSubjectId, SchemaAnnotationValue, SchemaDelta,
9 SchemaFact, SchemaFactId, SchemaOperation, SchemaOperationKind,
10};
11
12#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
14#[serde(rename_all = "snake_case")]
15pub enum SafetyClass {
16 FormalOnly,
17 SchemaMetadata,
18 Additive,
19 Conditional,
20 BackfillRequired,
21 Destructive,
22 Opaque,
23 Unsupported,
24}
25
26impl SafetyClass {
27 pub const ALL: [Self; 8] = [
28 Self::FormalOnly,
29 Self::SchemaMetadata,
30 Self::Additive,
31 Self::Conditional,
32 Self::BackfillRequired,
33 Self::Destructive,
34 Self::Opaque,
35 Self::Unsupported,
36 ];
37}
38
39pub type DeltaSafety = SafetyClass;
41
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum SafetyClassificationError {
45 UnchangedRelatesSpecialization,
47 RedefinitionCategoryChanged,
49}
50
51impl SafetyClassificationError {
52 pub const fn message(self) -> &'static str {
54 match self {
55 Self::UnchangedRelatesSpecialization => {
56 "relates redefinition does not change specialization"
57 }
58 Self::RedefinitionCategoryChanged => "schema redefinition changed fact category",
59 }
60 }
61}
62
63pub fn classify_operation_safety(
65 operation: &SchemaOperation,
66) -> Result<SafetyClass, SafetyClassificationError> {
67 let mut safety = SafetyClass::FormalOnly;
68 match operation.kind() {
69 SchemaOperationKind::Define => {
70 let facts = operation.defined_facts().expect("define exposes facts");
71 let functions = facts
72 .iter()
73 .filter_map(|fact| match fact {
74 SchemaFact::Function(function) => Some(function.id().clone()),
75 _ => None,
76 })
77 .collect::<BTreeSet<FunctionId>>();
78 for fact in facts {
79 safety = safety.max(classify_defined_fact(fact, &functions));
80 }
81 }
82 SchemaOperationKind::Redefine => {
83 safety = classify_redefinition(
84 operation
85 .expected_fact()
86 .expect("redefine exposes expected fact"),
87 operation
88 .replacement_fact()
89 .expect("redefine exposes replacement fact"),
90 )?;
91 }
92 SchemaOperationKind::Undefine => {
93 safety =
94 classify_undefined_fact(operation.undefined_fact().expect("undefine exposes fact"));
95 }
96 }
97 Ok(safety)
98}
99
100fn classify_defined_fact(fact: &SchemaFact, functions: &BTreeSet<FunctionId>) -> SafetyClass {
101 match fact {
102 SchemaFact::Relates(relates) if relates.specializes().is_some() => SafetyClass::Conditional,
103 SchemaFact::Annotation(annotation) => {
104 if let AnnotationSubjectId::Function(function) = annotation.id().subject()
105 && functions.contains(function)
106 && matches!(
107 annotation.id().kind(),
108 AnnotationKindId::Doc | AnnotationKindId::Meta(_)
109 )
110 {
111 SafetyClass::SchemaMetadata
112 } else {
113 classify_annotation(annotation, AnnotationTransition::Add, None)
114 }
115 }
116 _ => classify_fact(fact, FactTransition::Define),
117 }
118}
119
120fn classify_undefined_fact(fact: &SchemaFact) -> SafetyClass {
121 match fact {
122 SchemaFact::Annotation(annotation) => {
123 classify_annotation(annotation, AnnotationTransition::Remove, None)
124 }
125 SchemaFact::Relates(relates) if relates.specializes().is_some() => SafetyClass::Conditional,
126 _ => classify_fact(fact, FactTransition::Undefine),
127 }
128}
129
130fn classify_redefinition(
131 expected: &SchemaFact,
132 replacement: &SchemaFact,
133) -> Result<SafetyClass, SafetyClassificationError> {
134 match (expected, replacement) {
135 (SchemaFact::Relates(old), SchemaFact::Relates(new)) => {
136 match (old.specializes(), new.specializes()) {
137 (None, None) => Err(SafetyClassificationError::UnchangedRelatesSpecialization),
138 _ => Ok(SafetyClass::Conditional),
139 }
140 }
141 (SchemaFact::Annotation(old), SchemaFact::Annotation(new)) => Ok(classify_annotation(
142 new,
143 AnnotationTransition::Change,
144 Some(old),
145 )),
146 (left, right) if std::mem::discriminant(left) == std::mem::discriminant(right) => {
147 Ok(classify_fact(right, FactTransition::Redefine))
148 }
149 _ => Err(SafetyClassificationError::RedefinitionCategoryChanged),
150 }
151}
152
153#[derive(Clone, Copy)]
154enum FactTransition {
155 Define,
156 Undefine,
157 Redefine,
158}
159
160fn classify_fact(fact: &SchemaFact, transition: FactTransition) -> SafetyClass {
161 use FactTransition::{Define, Redefine, Undefine};
162 use SafetyClass::{Additive, Conditional, Destructive, Opaque, Unsupported};
163
164 match (fact, transition) {
165 (SchemaFact::Type(_), Define) => Additive,
166 (SchemaFact::Type(_), Undefine) => Destructive,
167 (SchemaFact::Type(_), Redefine) => Unsupported,
168 (SchemaFact::Sub(_), Define | Redefine) => Conditional,
169 (SchemaFact::Sub(_), Undefine) => Destructive,
170 (SchemaFact::Value(_), Define) => Additive,
171 (SchemaFact::Value(_), Undefine | Redefine) => Destructive,
172 (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Define) => Additive,
173 (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Undefine) => {
174 Destructive
175 }
176 (SchemaFact::Owns(_) | SchemaFact::Relates(_) | SchemaFact::Plays(_), Redefine) => {
177 Unsupported
178 }
179 (SchemaFact::Function(_), Define) => Additive,
180 (SchemaFact::Function(_), Undefine) => Destructive,
181 (SchemaFact::Function(_), Redefine) => Opaque,
182 (SchemaFact::Struct(_), _) => Unsupported,
183 (SchemaFact::Annotation(_), _) => {
184 unreachable!("annotations use the annotation classifier")
185 }
186 }
187}
188
189#[derive(Clone, Copy)]
190enum AnnotationTransition {
191 Add,
192 Change,
193 Remove,
194}
195
196fn classify_annotation(
197 annotation: &AnnotationFact,
198 transition: AnnotationTransition,
199 expected: Option<&AnnotationFact>,
200) -> SafetyClass {
201 let subject = annotation.id().subject();
202 let kind = annotation.id().kind();
203 if !annotation_supported(subject, kind) {
204 return SafetyClass::Unsupported;
205 }
206 if matches!(subject, AnnotationSubjectId::Sub(_))
207 || matches!(kind, AnnotationKindId::Doc | AnnotationKindId::Meta(_))
208 {
209 return SafetyClass::SchemaMetadata;
210 }
211
212 let mut safety = match kind {
213 AnnotationKindId::Abstract => match transition {
214 AnnotationTransition::Add => SafetyClass::Conditional,
215 AnnotationTransition::Remove => SafetyClass::Additive,
216 AnnotationTransition::Change => SafetyClass::Unsupported,
217 },
218 AnnotationKindId::Independent => match transition {
219 AnnotationTransition::Add => SafetyClass::Additive,
220 AnnotationTransition::Remove => SafetyClass::Destructive,
221 AnnotationTransition::Change => SafetyClass::Unsupported,
222 },
223 AnnotationKindId::Key | AnnotationKindId::Unique => match transition {
224 AnnotationTransition::Add => SafetyClass::BackfillRequired,
225 AnnotationTransition::Remove => SafetyClass::Additive,
226 AnnotationTransition::Change => SafetyClass::Unsupported,
227 },
228 AnnotationKindId::Card => SafetyClass::Conditional,
229 AnnotationKindId::Regex | AnnotationKindId::Range | AnnotationKindId::Values => {
230 match transition {
231 AnnotationTransition::Add | AnnotationTransition::Change => {
232 SafetyClass::Conditional
233 }
234 AnnotationTransition::Remove => SafetyClass::Additive,
235 }
236 }
237 AnnotationKindId::Doc | AnnotationKindId::Meta(_) => {
238 unreachable!("metadata annotations returned above")
239 }
240 };
241
242 if matches!(kind, AnnotationKindId::Card)
243 && let Some(target) = annotation_cardinality(annotation)
244 && let Some(default) = default_cardinality(subject)
245 {
246 let (from, to) = match transition {
247 AnnotationTransition::Add => (default, target),
248 AnnotationTransition::Change => {
249 let Some(source) = expected.and_then(annotation_cardinality) else {
250 return safety;
251 };
252 (source, target)
253 }
254 AnnotationTransition::Remove => (target, default),
255 };
256 safety = cardinality_transition_safety(from, to);
257 }
258 safety
259}
260
261fn annotation_supported(subject: &AnnotationSubjectId, kind: &AnnotationKindId) -> bool {
262 match subject {
263 AnnotationSubjectId::Type(_) => matches!(
264 kind,
265 AnnotationKindId::Abstract
266 | AnnotationKindId::Independent
267 | AnnotationKindId::Doc
268 | AnnotationKindId::Meta(_)
269 ),
270 AnnotationSubjectId::Sub(_) => {
271 matches!(kind, AnnotationKindId::Doc | AnnotationKindId::Meta(_))
272 }
273 AnnotationSubjectId::Value(_) => matches!(
274 kind,
275 AnnotationKindId::Regex
276 | AnnotationKindId::Range
277 | AnnotationKindId::Values
278 | AnnotationKindId::Doc
279 | AnnotationKindId::Meta(_)
280 ),
281 AnnotationSubjectId::Owns(_) => matches!(
282 kind,
283 AnnotationKindId::Key
284 | AnnotationKindId::Unique
285 | AnnotationKindId::Card
286 | AnnotationKindId::Regex
287 | AnnotationKindId::Range
288 | AnnotationKindId::Values
289 | AnnotationKindId::Doc
290 | AnnotationKindId::Meta(_)
291 ),
292 AnnotationSubjectId::Relates(_) => matches!(
293 kind,
294 AnnotationKindId::Abstract
295 | AnnotationKindId::Card
296 | AnnotationKindId::Doc
297 | AnnotationKindId::Meta(_)
298 ),
299 AnnotationSubjectId::Plays(_) => matches!(
300 kind,
301 AnnotationKindId::Card | AnnotationKindId::Doc | AnnotationKindId::Meta(_)
302 ),
303 AnnotationSubjectId::Function(_) => false,
304 }
305}
306
307fn annotation_cardinality(annotation: &AnnotationFact) -> Option<(u64, Option<u64>)> {
308 match annotation.value() {
309 SchemaAnnotationValue::Cardinality(cardinality) => {
310 Some(((*cardinality).min(), (*cardinality).max()))
311 }
312 _ => None,
313 }
314}
315
316fn default_cardinality(subject: &AnnotationSubjectId) -> Option<(u64, Option<u64>)> {
317 match subject {
318 AnnotationSubjectId::Owns(_) | AnnotationSubjectId::Relates(_) => Some((0, Some(1))),
319 AnnotationSubjectId::Plays(_) => Some((0, None)),
320 _ => None,
321 }
322}
323
324fn cardinality_transition_safety(from: (u64, Option<u64>), to: (u64, Option<u64>)) -> SafetyClass {
325 if from == to {
326 SafetyClass::FormalOnly
327 } else if interval_contains(to, from) {
328 SafetyClass::Additive
329 } else if interval_contains(from, to) {
330 SafetyClass::BackfillRequired
331 } else {
332 SafetyClass::Conditional
333 }
334}
335
336fn interval_contains(outer: (u64, Option<u64>), inner: (u64, Option<u64>)) -> bool {
337 outer.0 <= inner.0
338 && match (outer.1, inner.1) {
339 (None, _) => true,
340 (Some(_), None) => false,
341 (Some(outer), Some(inner)) => outer >= inner,
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct DeltaSafetyReason {
348 operation_index: usize,
349 fact_id: SchemaFactId,
350 classification: DeltaSafety,
351}
352
353impl DeltaSafetyReason {
354 #[must_use]
356 pub const fn operation_index(&self) -> usize {
357 self.operation_index
358 }
359
360 #[must_use]
362 pub const fn fact_id(&self) -> &SchemaFactId {
363 &self.fact_id
364 }
365
366 #[must_use]
368 pub const fn classification(&self) -> DeltaSafety {
369 self.classification
370 }
371}
372
373#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct DeltaSafetyReport {
376 classification: DeltaSafety,
377 reasons: Vec<DeltaSafetyReason>,
378}
379
380impl DeltaSafetyReport {
381 #[must_use]
383 pub const fn classification(&self) -> DeltaSafety {
384 self.classification
385 }
386
387 #[must_use]
389 pub fn reasons(&self) -> &[DeltaSafetyReason] {
390 &self.reasons
391 }
392}
393
394#[must_use]
396pub fn classify_schema_operation_safety(operation: &SchemaOperation) -> DeltaSafety {
397 classify_operation_safety(operation).unwrap_or(DeltaSafety::Unsupported)
398}
399
400#[must_use]
402pub fn classify_delta_safety(delta: &SchemaDelta) -> DeltaSafetyReport {
403 let mut reasons = Vec::new();
404 for (operation_index, operation) in delta.operations().iter().enumerate() {
405 let classification = classify_schema_operation_safety(operation);
406 for fact_id in operation.affected_ids() {
407 reasons.push(DeltaSafetyReason {
408 operation_index,
409 fact_id,
410 classification,
411 });
412 }
413 }
414 let classification = reasons
415 .iter()
416 .map(DeltaSafetyReason::classification)
417 .max()
418 .unwrap_or(DeltaSafety::FormalOnly);
419 DeltaSafetyReport {
420 classification,
421 reasons,
422 }
423}