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