1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4
5use miette::SourceSpan;
6use serde::de::DeserializeOwned;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use serde_json::Value;
9use tracing::instrument;
10
11macro_rules! numeric_id {
12 ($name:ident) => {
13 #[derive(
14 Clone,
15 Copy,
16 Debug,
17 Default,
18 Eq,
19 PartialEq,
20 Ord,
21 PartialOrd,
22 Hash,
23 Serialize,
24 Deserialize,
25 )]
26 #[serde(transparent)]
27 pub struct $name(u64);
28
29 impl $name {
30 #[must_use]
31 #[instrument(level = "trace")]
32 pub fn new(raw: u64) -> Self {
33 Self(raw)
34 }
35
36 #[must_use]
37 #[instrument(level = "trace", skip(self))]
38 pub fn get(self) -> u64 {
39 self.0
40 }
41 }
42 };
43}
44
45macro_rules! interned_name {
46 ($name:ident) => {
47 #[derive(
48 Clone,
49 Copy,
50 Debug,
51 Default,
52 Eq,
53 PartialEq,
54 Ord,
55 PartialOrd,
56 Hash,
57 Serialize,
58 Deserialize,
59 )]
60 #[serde(transparent)]
61 pub struct $name(SymbolId);
62
63 impl $name {
64 #[must_use]
65 #[instrument(level = "trace")]
66 pub fn new(symbol: SymbolId) -> Self {
67 Self(symbol)
68 }
69
70 #[must_use]
71 #[instrument(level = "trace", skip(self))]
72 pub fn symbol(self) -> SymbolId {
73 self.0
74 }
75 }
76
77 impl From<SymbolId> for $name {
78 fn from(value: SymbolId) -> Self {
79 Self::new(value)
80 }
81 }
82 };
83}
84
85#[derive(
86 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
87)]
88#[serde(transparent)]
89pub struct FileId(u32);
90
91impl FileId {
92 #[must_use]
93 #[instrument(level = "trace")]
94 pub fn new(raw: u32) -> Self {
95 Self(raw)
96 }
97
98 #[must_use]
99 #[instrument(level = "trace", skip(self))]
100 pub fn get(self) -> u32 {
101 self.0
102 }
103}
104
105numeric_id!(AnalysisRunId);
106numeric_id!(SymbolId);
107numeric_id!(ObjectId);
108numeric_id!(ColumnId);
109numeric_id!(MemberId);
110
111interned_name!(SchemaName);
112interned_name!(UserName);
113interned_name!(EditionName);
114interned_name!(RoleName);
115interned_name!(ObjectName);
116interned_name!(ColumnName);
117interned_name!(MemberName);
118
119#[derive(
120 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
121)]
122pub struct Position {
123 pub line: u32,
124 pub column: u32,
125 pub offset: u32,
126}
127
128impl Position {
129 #[must_use]
130 #[instrument(level = "trace")]
131 pub fn new(line: u32, column: u32, offset: u32) -> Self {
132 Self {
133 line,
134 column,
135 offset,
136 }
137 }
138}
139
140#[derive(
141 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
142)]
143pub struct Span {
144 pub file_id: FileId,
145 pub start: Position,
146 pub end: Position,
147}
148
149impl Span {
150 #[must_use]
151 #[instrument(level = "trace")]
152 pub fn new(file_id: FileId, start: Position, end: Position) -> Self {
153 Self {
154 file_id,
155 start,
156 end,
157 }
158 }
159
160 #[must_use]
161 #[instrument(level = "trace", skip(self))]
162 pub fn len(self) -> u32 {
163 self.end.offset.saturating_sub(self.start.offset)
164 }
165
166 #[must_use]
167 #[instrument(level = "trace", skip(self))]
168 pub fn is_empty(self) -> bool {
169 self.start.offset >= self.end.offset
170 }
171
172 #[must_use]
173 #[instrument(level = "trace", skip(self))]
174 pub fn source_span(self) -> SourceSpan {
175 SourceSpan::from((
176 usize::try_from(self.start.offset).unwrap_or(usize::MAX),
177 usize::try_from(self.len()).unwrap_or(usize::MAX),
178 ))
179 }
180}
181
182#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
183pub struct SpanLabel {
184 pub label: String,
185 pub span: Span,
186}
187
188impl SpanLabel {
189 #[must_use]
190 #[instrument(level = "trace", skip(label))]
191 pub fn new(label: impl Into<String>, span: Span) -> Self {
192 Self {
193 label: label.into(),
194 span,
195 }
196 }
197}
198
199#[derive(
200 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
201)]
202pub enum Severity {
203 #[default]
204 Info,
205 Warn,
206 Error,
207 Fatal,
208}
209
210#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
211pub enum UnknownReason {
212 DynamicSqlOpaque,
213 DbLinkRemoteObject,
214 WrappedSource,
215 MissingCatalogObject,
216 MissingPackageBody,
217 ConditionalCompilationBranch,
218 EditionedObject,
219 InvokerRightsRuntimeResolution,
220 RuntimeGrantOrRole,
221 UnsupportedDialectFeature,
222 ParserRecoveryRegion,
223 AnalysisRecursionLimit,
231 ResponseSanitized,
236}
237
238#[derive(
239 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
240)]
241pub enum ConfidenceLevel {
242 High,
243 Medium,
244 Low,
245 #[default]
246 Opaque,
247}
248
249#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
250pub struct Confidence {
251 pub level: ConfidenceLevel,
252 pub explanation: Option<String>,
253}
254
255impl Confidence {
256 #[must_use]
257 #[instrument(level = "trace", skip(explanation))]
258 pub fn new(level: ConfidenceLevel, explanation: impl Into<Option<String>>) -> Self {
259 Self {
260 level,
261 explanation: explanation.into(),
262 }
263 }
264
265 #[must_use]
266 #[instrument(level = "trace")]
267 pub fn opaque() -> Self {
268 Self {
269 level: ConfidenceLevel::Opaque,
270 explanation: None,
271 }
272 }
273}
274
275#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
276pub struct Evidence {
277 pub code: String,
278 pub summary: String,
279 pub spans: Vec<SpanLabel>,
280 pub notes: Vec<String>,
281 pub attributes: BTreeMap<String, Value>,
282 pub confidence: Option<Confidence>,
283}
284
285impl Evidence {
286 #[must_use]
287 #[instrument(level = "trace", skip(code, summary))]
288 pub fn new(code: impl Into<String>, summary: impl Into<String>) -> Self {
289 Self {
290 code: code.into(),
291 summary: summary.into(),
292 ..Self::default()
293 }
294 }
295
296 #[must_use]
297 #[instrument(level = "trace", skip(self))]
298 pub fn with_span(mut self, span: SpanLabel) -> Self {
299 self.spans.push(span);
300 self
301 }
302
303 #[must_use]
304 #[instrument(level = "trace", skip(self, note))]
305 pub fn with_note(mut self, note: impl Into<String>) -> Self {
306 self.notes.push(note.into());
307 self
308 }
309
310 #[must_use]
311 #[instrument(level = "trace", skip(self, key, value))]
312 pub fn with_attribute(mut self, key: impl Into<String>, value: Value) -> Self {
313 self.attributes.insert(key.into(), value);
314 self
315 }
316
317 #[must_use]
318 #[instrument(level = "trace", skip(self))]
319 pub fn with_confidence(mut self, confidence: Confidence) -> Self {
320 self.confidence = Some(confidence);
321 self
322 }
323}
324
325#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
326pub struct Diagnostic {
327 pub code: String,
328 pub severity: Severity,
329 pub message: String,
330 pub primary_span: Option<Span>,
331 pub related_spans: Vec<SpanLabel>,
332 pub help: Option<String>,
333 pub unknown_reasons: Vec<UnknownReason>,
334 pub evidence: Vec<Evidence>,
335}
336
337impl Diagnostic {
338 #[must_use]
339 #[instrument(level = "trace", skip(code, message))]
340 pub fn new(code: impl Into<String>, severity: Severity, message: impl Into<String>) -> Self {
341 Self {
342 code: code.into(),
343 severity,
344 message: message.into(),
345 ..Self::default()
346 }
347 }
348
349 #[must_use]
350 #[instrument(level = "trace", skip(self))]
351 pub fn with_primary_span(mut self, span: Span) -> Self {
352 self.primary_span = Some(span);
353 self
354 }
355
356 #[must_use]
357 #[instrument(level = "trace", skip(self))]
358 pub fn with_related_span(mut self, span: SpanLabel) -> Self {
359 self.related_spans.push(span);
360 self
361 }
362
363 #[must_use]
364 #[instrument(level = "trace", skip(self, help))]
365 pub fn with_help(mut self, help: impl Into<String>) -> Self {
366 self.help = Some(help.into());
367 self
368 }
369
370 #[must_use]
371 #[instrument(level = "trace", skip(self))]
372 pub fn with_unknown_reason(mut self, reason: UnknownReason) -> Self {
373 self.unknown_reasons.push(reason);
374 self
375 }
376
377 #[must_use]
378 #[instrument(level = "trace", skip(self))]
379 pub fn with_evidence(mut self, evidence: Evidence) -> Self {
380 self.evidence.push(evidence);
381 self
382 }
383}
384
385pub trait JsonExportable: Serialize + DeserializeOwned {
386 #[instrument(level = "trace", skip(self))]
387 fn to_json_value(&self) -> serde_json::Result<Value> {
388 serde_json::to_value(self)
389 }
390
391 fn from_json_value(value: Value) -> serde_json::Result<Self>
392 where
393 Self: Sized,
394 {
395 serde_json::from_value(value)
396 }
397}
398
399impl<T> JsonExportable for T where T: Serialize + DeserializeOwned {}
400
401#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
402pub struct RobotJson<T> {
403 pub payload: T,
404}
405
406impl<T> RobotJson<T> {
407 #[must_use]
408 #[instrument(level = "trace", skip(payload))]
409 pub fn new(payload: T) -> Self {
410 Self { payload }
411 }
412
413 #[must_use]
414 #[instrument(level = "trace", skip(self))]
415 pub fn into_payload(self) -> T {
416 self.payload
417 }
418}
419
420#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
421pub enum LiteralValue {
422 String(String),
423 Integer(i64),
424 Decimal(String),
425 Boolean(bool),
426 Null,
427}
428
429#[derive(
430 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
431)]
432pub enum NlsLengthSemantics {
433 #[default]
434 Byte,
435 Char,
436}
437
438#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
439pub struct NlsSettings {
440 pub language: Option<String>,
441 pub territory: Option<String>,
442 pub date_format: Option<String>,
443 pub timestamp_format: Option<String>,
444 pub timestamp_tz_format: Option<String>,
445 pub length_semantics: NlsLengthSemantics,
446}
447
448#[derive(
449 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
450)]
451pub enum DbLinkPolicy {
452 AllowRemoteMetadata,
453 #[default]
454 RecordOpaqueRemoteObjects,
455 RejectRemoteObjects,
456}
457
458#[derive(
459 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
460)]
461pub enum UnknownFeatureBehavior {
462 #[default]
463 RecordUnknown,
464 TreatAsUnsupported,
465 FailAnalysis,
466}
467
468#[derive(
469 Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
470)]
471pub enum OracleVersion {
472 Oracle11g,
473 Oracle12c,
474 #[default]
475 Oracle19c,
476 Oracle21c,
477 Oracle23ai,
478 Oracle26ai,
479}
480
481#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
482pub enum OracleFeature {
483 SqlBoolean23ai,
484 PlsqlVector23ai,
485 BinaryVector26ai,
486 SparseVector26ai,
487 VectorArithmetic26ai,
488 PackageResettable26ai,
489 JsonRelationalDuality23ai,
490 SqlMacros,
491 PolymorphicTableFunctions,
492 MultilingualEngineCallSpecs,
493}
494
495impl OracleVersion {
496 #[must_use]
497 #[instrument(level = "trace", skip(self))]
498 pub fn default_features(self) -> BTreeSet<OracleFeature> {
499 let features = match self {
500 Self::Oracle11g | Self::Oracle12c | Self::Oracle19c => Vec::new(),
501 Self::Oracle21c => vec![
502 OracleFeature::SqlMacros,
503 OracleFeature::PolymorphicTableFunctions,
504 ],
505 Self::Oracle23ai => vec![
506 OracleFeature::SqlBoolean23ai,
507 OracleFeature::PlsqlVector23ai,
508 OracleFeature::JsonRelationalDuality23ai,
509 OracleFeature::SqlMacros,
510 OracleFeature::PolymorphicTableFunctions,
511 ],
512 Self::Oracle26ai => vec![
513 OracleFeature::SqlBoolean23ai,
514 OracleFeature::PlsqlVector23ai,
515 OracleFeature::BinaryVector26ai,
516 OracleFeature::SparseVector26ai,
517 OracleFeature::VectorArithmetic26ai,
518 OracleFeature::PackageResettable26ai,
519 OracleFeature::JsonRelationalDuality23ai,
520 OracleFeature::SqlMacros,
521 OracleFeature::PolymorphicTableFunctions,
522 OracleFeature::MultilingualEngineCallSpecs,
523 ],
524 };
525
526 features.into_iter().collect()
527 }
528}
529
530#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
531pub struct FeaturePolicy {
532 pub enabled: BTreeSet<OracleFeature>,
533 pub disabled: BTreeSet<OracleFeature>,
534 pub unknown_feature_behavior: UnknownFeatureBehavior,
535}
536
537impl FeaturePolicy {
538 #[must_use]
539 #[instrument(level = "trace")]
540 pub fn from_version(version: OracleVersion) -> Self {
541 Self {
542 enabled: version.default_features(),
543 disabled: BTreeSet::new(),
544 unknown_feature_behavior: UnknownFeatureBehavior::RecordUnknown,
545 }
546 }
547
548 #[must_use]
549 #[instrument(level = "trace", skip(self))]
550 pub fn is_enabled(&self, feature: OracleFeature) -> bool {
551 !self.disabled.contains(&feature) && self.enabled.contains(&feature)
552 }
553
554 #[must_use]
555 #[instrument(level = "trace", skip(self))]
556 pub fn with_enabled(mut self, feature: OracleFeature) -> Self {
557 self.disabled.remove(&feature);
558 self.enabled.insert(feature);
559 self
560 }
561
562 #[must_use]
563 #[instrument(level = "trace", skip(self))]
564 pub fn with_disabled(mut self, feature: OracleFeature) -> Self {
565 self.enabled.remove(&feature);
566 self.disabled.insert(feature);
567 self
568 }
569}
570
571#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
572pub struct AnalysisProfile {
573 pub oracle_version: OracleVersion,
574 pub compatibility: Option<OracleVersion>,
575 pub feature_policy: FeaturePolicy,
576 pub current_schema: Option<SchemaName>,
577 pub current_user: Option<UserName>,
578 pub current_edition: Option<EditionName>,
579 pub plsql_ccflags: HashMap<String, LiteralValue>,
580 pub nls: NlsSettings,
581 pub enabled_roles: Vec<RoleName>,
582 pub db_link_policy: DbLinkPolicy,
583}
584
585impl Default for AnalysisProfile {
586 fn default() -> Self {
587 Self::for_version(OracleVersion::Oracle19c)
588 }
589}
590
591impl AnalysisProfile {
592 #[must_use]
593 #[instrument(level = "trace")]
594 pub fn for_version(oracle_version: OracleVersion) -> Self {
595 Self {
596 oracle_version,
597 compatibility: None,
598 feature_policy: FeaturePolicy::from_version(oracle_version),
599 current_schema: None,
600 current_user: None,
601 current_edition: None,
602 plsql_ccflags: HashMap::new(),
603 nls: NlsSettings::default(),
604 enabled_roles: Vec::new(),
605 db_link_policy: DbLinkPolicy::default(),
606 }
607 }
608
609 #[must_use]
610 #[instrument(level = "trace", skip(self))]
611 pub fn supports_feature(&self, feature: OracleFeature) -> bool {
612 self.feature_policy.is_enabled(feature)
613 }
614}
615
616#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
626pub enum Measured<T> {
627 Measured(T),
629 #[default]
632 Unmeasured,
633}
634
635impl<T> Measured<T> {
636 pub fn measured(self) -> Option<T> {
638 match self {
639 Self::Measured(v) => Some(v),
640 Self::Unmeasured => None,
641 }
642 }
643
644 #[must_use]
645 pub fn is_measured(&self) -> bool {
646 matches!(self, Self::Measured(_))
647 }
648}
649
650mod measured_serde {
654 use super::Measured;
655 use serde::{Deserialize, Serialize};
656
657 #[derive(Serialize, Deserialize)]
658 struct UnmeasuredMarker {
659 unmeasured: bool,
660 }
661
662 impl<T: Serialize> Serialize for Measured<T> {
663 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
664 match self {
665 Measured::Measured(v) => v.serialize(s),
666 Measured::Unmeasured => UnmeasuredMarker { unmeasured: true }.serialize(s),
667 }
668 }
669 }
670
671 impl<'de, T: serde::de::DeserializeOwned> Deserialize<'de> for Measured<T> {
672 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
673 let value = serde_json::Value::deserialize(d)?;
674 if let Ok(m) = serde_json::from_value::<UnmeasuredMarker>(value.clone()) {
675 if m.unmeasured {
676 return Ok(Measured::Unmeasured);
677 }
678 }
679 let v = serde_json::from_value::<T>(value).map_err(serde::de::Error::custom)?;
680 Ok(Measured::Measured(v))
681 }
682 }
683}
684
685#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
693pub enum CompletenessPosture {
694 Clean,
696 Partial,
698 LowConfidence,
702 #[default]
704 Degraded,
705}
706
707impl std::fmt::Display for CompletenessPosture {
708 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
709 f.write_str(match self {
710 Self::Clean => "Clean",
711 Self::Partial => "Partial",
712 Self::LowConfidence => "LowConfidence",
713 Self::Degraded => "Degraded",
714 })
715 }
716}
717
718#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
719pub struct CompletenessReport {
720 pub files_total: usize,
721 pub files_parsed_cleanly: usize,
722 pub files_recovered: usize,
723 pub skipped_token_ratio: f32,
724 pub objects_total: usize,
725 pub objects_with_source: usize,
726 pub objects_catalog_only: usize,
727 pub wrapped_units: Measured<usize>,
732 pub missing_package_bodies: Measured<usize>,
733 pub dynamic_sql_sites: Measured<usize>,
734 pub opaque_dynamic_sql_sites: Measured<usize>,
735 pub db_link_edges: Measured<usize>,
736 pub unresolved_references: Measured<usize>,
737 pub diagnostics_total: usize,
742 pub objects_unrecognized: usize,
746 pub objects_with_extracted_semantics: usize,
748 pub extracted_semantics_ratio: f32,
751 pub posture: CompletenessPosture,
753 pub catalog_available: bool,
754 pub plscope_available: bool,
755}
756
757impl CompletenessReport {
758 pub fn finalize_posture(&mut self) {
774 let denom = self
775 .objects_with_extracted_semantics
776 .saturating_add(self.objects_unrecognized);
777 self.extracted_semantics_ratio = if denom == 0 {
778 if self.diagnostics_total == 0 {
782 1.0
783 } else {
784 0.0
785 }
786 } else {
787 self.objects_with_extracted_semantics as f32 / denom as f32
788 };
789
790 let attempted = denom.max(self.objects_total);
795 let high_diag_pressure = attempted > 0 && self.diagnostics_total * 2 >= attempted;
796
797 self.posture = if denom == 0 && self.objects_total == 0 {
798 if self.files_total == 0 || self.diagnostics_total == 0 {
803 CompletenessPosture::Clean
804 } else {
805 CompletenessPosture::Degraded
806 }
807 } else if self.extracted_semantics_ratio < 0.10 {
808 CompletenessPosture::Degraded
809 } else if self.objects_unrecognized > 0
810 || self.extracted_semantics_ratio < 0.85
811 || high_diag_pressure
812 {
813 CompletenessPosture::LowConfidence
814 } else if self.files_recovered > 0
815 || self.diagnostics_total > 0
816 || self.files_parsed_cleanly < self.files_total
817 {
818 CompletenessPosture::Partial
819 } else {
820 CompletenessPosture::Clean
821 };
822 }
823}
824
825#[derive(Clone, Debug, Default, Eq, PartialEq)]
826pub struct SymbolInterner {
827 symbols: Vec<String>,
828 index: HashMap<String, SymbolId>,
829}
830
831impl SymbolInterner {
832 #[must_use]
833 #[instrument(level = "trace")]
834 pub fn new() -> Self {
835 Self::default()
836 }
837
838 #[must_use]
839 #[instrument(level = "trace", skip(self, text))]
840 pub fn intern(&mut self, text: impl Into<String>) -> Option<SymbolId> {
841 let text = text.into();
842 if let Some(&symbol_id) = self.index.get(text.as_str()) {
843 return Some(symbol_id);
844 }
845
846 let next_index = u64::try_from(self.symbols.len()).ok()?;
847 let symbol_id = SymbolId::new(next_index);
848 self.symbols.push(text.clone());
849 self.index.insert(text, symbol_id);
850 Some(symbol_id)
851 }
852
853 #[must_use]
854 #[instrument(level = "trace", skip(self))]
855 pub fn resolve(&self, symbol_id: SymbolId) -> Option<&str> {
856 let index = usize::try_from(symbol_id.get()).ok()?;
857 self.symbols.get(index).map(String::as_str)
858 }
859
860 #[must_use]
861 #[instrument(level = "trace", skip(self, text))]
862 pub fn contains(&self, text: impl AsRef<str>) -> bool {
863 self.index.contains_key(text.as_ref())
864 }
865
866 #[must_use]
867 #[instrument(level = "trace", skip(self))]
868 pub fn len(&self) -> usize {
869 self.symbols.len()
870 }
871
872 #[must_use]
873 #[instrument(level = "trace", skip(self))]
874 pub fn is_empty(&self) -> bool {
875 self.symbols.is_empty()
876 }
877
878 #[must_use]
879 #[instrument(level = "trace", skip(self, text))]
880 pub fn intern_schema_name(&mut self, text: impl Into<String>) -> Option<SchemaName> {
881 self.intern(text).map(SchemaName::from)
882 }
883
884 #[must_use]
885 #[instrument(level = "trace", skip(self, text))]
886 pub fn intern_user_name(&mut self, text: impl Into<String>) -> Option<UserName> {
887 self.intern(text).map(UserName::from)
888 }
889
890 #[must_use]
891 #[instrument(level = "trace", skip(self, text))]
892 pub fn intern_role_name(&mut self, text: impl Into<String>) -> Option<RoleName> {
893 self.intern(text).map(RoleName::from)
894 }
895}
896
897impl Serialize for SymbolInterner {
898 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
899 where
900 S: Serializer,
901 {
902 self.symbols.serialize(serializer)
903 }
904}
905
906impl<'de> Deserialize<'de> for SymbolInterner {
907 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
908 where
909 D: Deserializer<'de>,
910 {
911 let symbols = Vec::<String>::deserialize(deserializer)?;
912 let mut interner = SymbolInterner::default();
913 for symbol in symbols {
914 interner
915 .intern(symbol)
916 .ok_or_else(|| serde::de::Error::custom("symbol table overflow"))?;
917 }
918 Ok(interner)
919 }
920}
921
922impl std::fmt::Display for NlsLengthSemantics {
923 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
924 match self {
925 Self::Byte => f.write_str("Byte"),
926 Self::Char => f.write_str("Char"),
927 }
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::{
934 AnalysisProfile, ColumnName, CompletenessPosture, CompletenessReport, Confidence,
935 ConfidenceLevel, DbLinkPolicy, Diagnostic, EditionName, Evidence, FeaturePolicy, FileId,
936 JsonExportable, LiteralValue, Measured, NlsSettings, ObjectName, OracleFeature,
937 OracleVersion, Position, RobotJson, RoleName, SchemaName, Severity, SourceSpan, Span,
938 SymbolId, SymbolInterner, UnknownFeatureBehavior, UnknownReason, UserName,
939 };
940 use serde_json::Value;
941
942 #[test]
943 fn span_len_uses_offsets() {
944 let span = Span::new(
945 FileId::new(7),
946 Position::new(2, 4, 10),
947 Position::new(2, 9, 21),
948 );
949
950 assert_eq!(span.len(), 11);
951 assert!(!span.is_empty());
952 assert_eq!(span.source_span(), SourceSpan::from((10usize, 11usize)));
953 }
954
955 #[test]
956 fn evidence_builder_retains_attributes() {
957 let evidence = Evidence::new("SYM001", "resolved via same-schema lookup")
958 .with_note("package body available")
959 .with_attribute("strategy", Value::String(String::from("same-schema")))
960 .with_confidence(Confidence::new(
961 ConfidenceLevel::High,
962 Some(String::from("catalog snapshot and source agree")),
963 ));
964
965 assert_eq!(evidence.code, "SYM001");
966 assert_eq!(evidence.notes, [String::from("package body available")]);
967 assert_eq!(
968 evidence.attributes.get("strategy"),
969 Some(&Value::String(String::from("same-schema")))
970 );
971 assert_eq!(
972 evidence.confidence,
973 Some(Confidence::new(
974 ConfidenceLevel::High,
975 Some(String::from("catalog snapshot and source agree")),
976 ))
977 );
978 }
979
980 #[test]
981 fn diagnostic_builder_captures_unknowns() {
982 let span = Span::new(
983 FileId::new(1),
984 Position::new(4, 1, 20),
985 Position::new(4, 14, 33),
986 );
987 let diagnostic = Diagnostic::new(
988 "PARSE001",
989 Severity::Warn,
990 "parser recovered after unsupported token",
991 )
992 .with_primary_span(span)
993 .with_unknown_reason(UnknownReason::ParserRecoveryRegion)
994 .with_help("review the recovered region before trusting downstream analysis");
995
996 assert_eq!(diagnostic.primary_span, Some(span));
997 assert_eq!(
998 diagnostic.unknown_reasons,
999 vec![UnknownReason::ParserRecoveryRegion]
1000 );
1001 assert_eq!(
1002 diagnostic.help,
1003 Some(String::from(
1004 "review the recovered region before trusting downstream analysis"
1005 ))
1006 );
1007 }
1008
1009 #[test]
1010 fn symbol_interner_deduplicates_and_resolves_names() {
1011 let mut interner = SymbolInterner::new();
1012 let first = interner.intern("claims_pkg");
1013 let second = interner.intern("claims_pkg");
1014 let schema = interner.intern_schema_name("billing");
1015 let role = interner.intern_role_name("app_reader");
1016
1017 assert_eq!(first, second);
1018 assert_eq!(interner.len(), 3);
1019 assert_eq!(
1020 first.and_then(|symbol_id| interner.resolve(symbol_id)),
1021 Some("claims_pkg")
1022 );
1023 assert_eq!(schema.map(SchemaName::symbol), interner.intern("billing"));
1024 assert_eq!(role.map(RoleName::symbol), interner.intern("app_reader"));
1025 }
1026
1027 #[test]
1028 fn analysis_profile_uses_version_feature_defaults() {
1029 let base = AnalysisProfile::default();
1030 let modern = AnalysisProfile::for_version(OracleVersion::Oracle26ai);
1031
1032 assert_eq!(base.oracle_version, OracleVersion::Oracle19c);
1033 assert!(!base.supports_feature(OracleFeature::SqlBoolean23ai));
1034 assert!(modern.supports_feature(OracleFeature::PackageResettable26ai));
1035 assert!(modern.supports_feature(OracleFeature::MultilingualEngineCallSpecs));
1036 }
1037
1038 #[test]
1039 fn feature_policy_supports_explicit_overrides() {
1040 let policy = FeaturePolicy::from_version(OracleVersion::Oracle19c)
1041 .with_enabled(OracleFeature::SqlBoolean23ai)
1042 .with_disabled(OracleFeature::SqlBoolean23ai);
1043
1044 assert!(!policy.is_enabled(OracleFeature::SqlBoolean23ai));
1045 assert_eq!(
1046 policy.unknown_feature_behavior,
1047 UnknownFeatureBehavior::RecordUnknown
1048 );
1049 }
1050
1051 #[test]
1052 fn robot_json_round_trips_json_exportable_payloads() {
1053 let report = CompletenessReport {
1054 files_total: 8,
1055 files_parsed_cleanly: 7,
1056 files_recovered: 1,
1057 ..CompletenessReport::default()
1058 };
1059 let wrapped = RobotJson::new(report);
1060 let value = wrapped.to_json_value().expect("wrapper should serialize");
1061 let parsed = RobotJson::<CompletenessReport>::from_json_value(value)
1062 .expect("wrapper should deserialize");
1063
1064 assert_eq!(parsed.payload.files_total, 8);
1065 assert_eq!(parsed.payload.files_recovered, 1);
1066 }
1067
1068 #[test]
1069 fn names_and_policy_types_have_stable_defaults() {
1070 let schema = SchemaName::from(SymbolId::new(3));
1071 let user = UserName::from(SymbolId::new(4));
1072 let edition = EditionName::from(SymbolId::new(5));
1073 let object = ObjectName::from(SymbolId::new(6));
1074 let column = ColumnName::from(SymbolId::new(7));
1075
1076 assert_eq!(schema.symbol().get(), 3);
1077 assert_eq!(user.symbol().get(), 4);
1078 assert_eq!(edition.symbol().get(), 5);
1079 assert_eq!(object.symbol().get(), 6);
1080 assert_eq!(column.symbol().get(), 7);
1081 assert_eq!(
1082 DbLinkPolicy::default(),
1083 DbLinkPolicy::RecordOpaqueRemoteObjects
1084 );
1085 assert_eq!(NlsSettings::default().length_semantics.to_string(), "Byte");
1086 assert_eq!(LiteralValue::Boolean(true), LiteralValue::Boolean(true));
1087 }
1088
1089 #[test]
1090 fn default_features_are_monotonic_across_versions() {
1091 let ordered = [
1100 OracleVersion::Oracle11g,
1101 OracleVersion::Oracle12c,
1102 OracleVersion::Oracle19c,
1103 OracleVersion::Oracle21c,
1104 OracleVersion::Oracle23ai,
1105 OracleVersion::Oracle26ai,
1106 ];
1107 for pair in ordered.windows(2) {
1108 let [older, newer] = [pair[0], pair[1]];
1109 let older_f = older.default_features();
1110 let newer_f = newer.default_features();
1111 assert!(
1112 older_f.is_subset(&newer_f),
1113 "{older:?} features must remain available in {newer:?}; missing: {:?}",
1114 older_f.difference(&newer_f).collect::<Vec<_>>()
1115 );
1116 }
1117 }
1118
1119 #[test]
1122 fn low_extraction_run_is_never_clean() {
1123 let mut r = CompletenessReport {
1127 files_total: 4251,
1128 files_parsed_cleanly: 4224,
1129 files_recovered: 27,
1130 objects_total: 4123,
1131 objects_with_source: 4123,
1132 objects_with_extracted_semantics: 4123,
1133 objects_unrecognized: 6609,
1134 diagnostics_total: 6784,
1135 ..CompletenessReport::default()
1136 };
1137 r.finalize_posture();
1138
1139 assert_ne!(
1140 r.posture,
1141 CompletenessPosture::Clean,
1142 "a run that failed to recognise 6609 objects must not present as Clean"
1143 );
1144 assert_eq!(r.objects_unrecognized, 6609);
1145 assert_eq!(r.diagnostics_total, 6784);
1146 assert!(
1147 r.extracted_semantics_ratio < 0.85,
1148 "ratio {} should reflect heavy non-extraction",
1149 r.extracted_semantics_ratio
1150 );
1151 assert_eq!(r.dynamic_sql_sites, Measured::Unmeasured);
1153 assert_eq!(r.unresolved_references, Measured::Unmeasured);
1154 assert!(!r.dynamic_sql_sites.is_measured());
1155 }
1156
1157 #[test]
1158 fn clean_input_still_reads_clean() {
1159 let mut r = CompletenessReport {
1162 files_total: 12,
1163 files_parsed_cleanly: 12,
1164 files_recovered: 0,
1165 objects_total: 30,
1166 objects_with_source: 30,
1167 objects_with_extracted_semantics: 30,
1168 objects_unrecognized: 0,
1169 diagnostics_total: 0,
1170 ..CompletenessReport::default()
1171 };
1172 r.finalize_posture();
1173 assert_eq!(r.posture, CompletenessPosture::Clean);
1174 assert!((r.extracted_semantics_ratio - 1.0).abs() < f32::EPSILON);
1175 }
1176
1177 #[test]
1178 fn unmeasured_gap_metric_serializes_honestly_not_as_zero() {
1179 let r = CompletenessReport::default();
1180 let v = serde_json::to_value(&r).expect("serializes");
1181 assert_eq!(
1183 v["dynamic_sql_sites"],
1184 serde_json::json!({"unmeasured": true})
1185 );
1186 assert_ne!(v["dynamic_sql_sites"], serde_json::json!(0));
1187 let back: CompletenessReport = serde_json::from_value(v).expect("round-trips");
1189 assert_eq!(back.dynamic_sql_sites, Measured::Unmeasured);
1190
1191 let r2 = CompletenessReport {
1193 dynamic_sql_sites: Measured::Measured(7),
1194 ..CompletenessReport::default()
1195 };
1196 let v2 = serde_json::to_value(&r2).expect("serializes");
1197 assert_eq!(v2["dynamic_sql_sites"], serde_json::json!(7));
1198 let back2: CompletenessReport = serde_json::from_value(v2).expect("round-trips");
1199 assert_eq!(back2.dynamic_sql_sites, Measured::Measured(7));
1200 }
1201
1202 #[test]
1203 fn degraded_when_nothing_understood() {
1204 let mut r = CompletenessReport {
1205 files_total: 100,
1206 files_parsed_cleanly: 0,
1207 objects_total: 500,
1208 objects_unrecognized: 500,
1209 objects_with_extracted_semantics: 0,
1210 diagnostics_total: 500,
1211 ..CompletenessReport::default()
1212 };
1213 r.finalize_posture();
1214 assert_eq!(r.posture, CompletenessPosture::Degraded);
1215 }
1216}