1use super::{
2 AvailabilityContext, IndicatorAvailability, IndicatorDefinition, IndicatorDefinitionError,
3 IndicatorHistoryRequirement, IndicatorInputRequirement, IndicatorInputSource, IndicatorSetting,
4 IndicatorSettingValue, IndicatorSettingsContext, IndicatorUnsupportedReason,
5};
6use crate::chart::Basis;
7use crate::util::stable_hash;
8use exchange::UnixMs;
9use indicator_engine::CompiledIndicator;
10use rustc_hash::{FxHashMap, FxHashSet};
11use serde::{Deserialize, Serialize};
12use std::fmt::{Display, Formatter};
13use std::sync::Arc;
14use std::sync::OnceLock;
15
16static CATALOG: OnceLock<IndicatorCatalog> = OnceLock::new();
17static EMPTY_CATALOG: std::sync::LazyLock<IndicatorCatalog> =
18 std::sync::LazyLock::new(|| IndicatorCatalog::from_registrations(Vec::new()));
19
20pub fn catalog() -> &'static IndicatorCatalog {
21 CATALOG.get().unwrap_or(&EMPTY_CATALOG)
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct IndicatorId(indicator_engine::IndicatorId);
27
28impl IndicatorId {
29 pub fn new(id: impl Into<String>) -> Result<Self, IndicatorIdError> {
30 indicator_engine::IndicatorId::new(id)
31 .map(Self)
32 .map_err(IndicatorIdError)
33 }
34
35 pub fn parse(id: &str) -> Result<Self, IndicatorIdError> {
36 Self::new(id)
37 }
38
39 pub fn id(&self) -> &str {
40 self.0.as_str()
41 }
42
43 pub fn as_str(&self) -> &str {
44 self.id()
45 }
46
47 pub fn default_instance_slot(&self) -> u64 {
48 stable_hash(self.id().as_bytes()) & 0x0000_7fff_ffff_ffff
49 }
50
51 pub fn is_registered(&self) -> bool {
52 catalog().get(self).is_some()
53 }
54
55 pub fn definition(&self) -> &'static IndicatorDefinition {
56 catalog()
57 .get(self)
58 .map(|registration| ®istration.definition)
59 .unwrap_or_else(missing_definition)
60 }
61
62 pub fn default_config(&self) -> IndicatorConfig {
63 catalog()
64 .get(self)
65 .map(|registration| registration.default_config())
66 .unwrap_or_default()
67 }
68
69 fn normalized_config(&self, config: IndicatorConfig) -> IndicatorConfig {
70 catalog()
71 .get(self)
72 .map(|registration| registration.normalize_config(config.clone()))
73 .unwrap_or(config)
74 }
75
76 pub fn settings(
77 &self,
78 config: &IndicatorConfig,
79 context: IndicatorSettingsContext,
80 ) -> Result<Vec<IndicatorSetting>, IndicatorConfigError> {
81 catalog()
82 .get(self)
83 .ok_or_else(|| IndicatorConfigError::MissingIndicator(self.clone()))
84 .and_then(|registration| registration.settings(config.clone(), context))
85 }
86
87 pub fn with_setting(
88 &self,
89 config: IndicatorConfig,
90 id: &str,
91 value: IndicatorSettingValue,
92 ) -> Option<IndicatorConfig> {
93 catalog()
94 .get(self)
95 .and_then(|registration| registration.with_setting(config, id, value))
96 }
97
98 pub fn history_requirement(
99 &self,
100 config: IndicatorConfig,
101 basis: Basis,
102 reference_time: UnixMs,
103 ) -> IndicatorHistoryRequirement {
104 catalog()
105 .get(self)
106 .map(|registration| registration.history_requirement(config, basis, reference_time))
107 .unwrap_or_default()
108 }
109
110 pub(crate) fn history_requirement_with_warmup(
111 &self,
112 config: IndicatorConfig,
113 basis: Basis,
114 reference_time: UnixMs,
115 warmup_bars: Option<u64>,
116 ) -> IndicatorHistoryRequirement {
117 catalog()
118 .get(self)
119 .map(|registration| {
120 registration.history_requirement_with_warmup(
121 config,
122 basis,
123 reference_time,
124 warmup_bars,
125 )
126 })
127 .unwrap_or_default()
128 }
129
130 pub fn warmup_bars(&self, config: &IndicatorConfig) -> Option<u64> {
131 catalog()
132 .get(self)
133 .and_then(|registration| registration.warmup_bars(config))
134 }
135
136 pub fn unsupported_reason(
137 &self,
138 context: AvailabilityContext,
139 ) -> Option<IndicatorUnsupportedReason> {
140 let Some(registration) = catalog().get(self) else {
141 return Some(IndicatorUnsupportedReason::NotInstalled);
142 };
143 registration.unsupported_reason(context)
144 }
145
146 pub fn availability(&self, context: AvailabilityContext) -> IndicatorAvailability {
147 if let Some(reason) = self.unsupported_reason(context) {
148 return IndicatorAvailability::Unsupported(reason);
149 }
150 IndicatorAvailability::Available
151 }
152
153 pub fn declared_input_requirements(&self) -> Vec<IndicatorInputRequirement> {
154 self.definition().inputs.to_vec()
155 }
156
157 pub fn output_channels(&self) -> &'static [super::OutputChannelSpec] {
158 catalog()
159 .get(self)
160 .map(|registration| registration.definition.outputs.as_slice())
161 .unwrap_or(&[])
162 }
163
164 pub fn default_view(&self) -> &'static super::IndicatorPanelRecipe {
165 self.definition().default_view()
166 }
167
168 pub fn requires_trade_data(&self) -> bool {
169 self.definition().requires(IndicatorInputSource::Trades)
170 }
171
172 pub fn color_specs(&self) -> &'static [super::IndicatorColorSpec] {
173 catalog()
174 .get(self)
175 .map(|registration| registration.definition.style_slots.as_slice())
176 .unwrap_or(&[])
177 }
178
179 pub fn mark_specs(&self) -> &'static [super::IndicatorMarkSpec] {
180 catalog()
181 .get(self)
182 .map(|registration| registration.definition.mark_specs.as_slice())
183 .unwrap_or(&[])
184 }
185
186 pub fn segment_specs(&self) -> &'static [super::IndicatorSegmentSpec] {
187 catalog()
188 .get(self)
189 .map(|registration| registration.definition.segment_specs.as_slice())
190 .unwrap_or(&[])
191 }
192
193 pub fn region_style_slots(&self) -> &'static [String] {
194 catalog()
195 .get(self)
196 .map(|registration| registration.definition.region_style_slots.as_slice())
197 .unwrap_or(&[])
198 }
199
200 pub fn mark_source_channel(&self, mark: super::MarkKind) -> Option<&'static str> {
201 self.mark_spec(mark)
202 .map(|spec| spec.source_channel.as_str())
203 }
204
205 pub fn mark_spec(&self, mark: super::MarkKind) -> Option<&'static super::IndicatorMarkSpec> {
206 self.mark_specs().iter().find(|spec| spec.mark == mark)
207 }
208
209 pub fn fallback_mark_for(&self, mark: super::MarkKind) -> Option<super::MarkKind> {
210 self.mark_spec(mark).and_then(|spec| spec.fallback_mark)
211 }
212
213 pub fn overlay_channels(&self) -> &'static [super::IndicatorOverlayChannelSpec] {
214 catalog()
215 .get(self)
216 .map(|registration| registration.definition.overlay_channels.as_slice())
217 .unwrap_or(&[])
218 }
219
220 pub fn readout_specs(&self) -> &'static [super::IndicatorReadoutSpec] {
221 match self.default_view() {
222 super::IndicatorPanelRecipe::Plot { readouts, .. } => readouts,
223 _ => &[],
224 }
225 }
226
227 pub fn fill_specs(&self) -> &'static [super::IndicatorFillSpec] {
228 catalog()
229 .get(self)
230 .map(|registration| registration.definition.fill_specs.as_slice())
231 .unwrap_or(&[])
232 }
233
234 pub fn mark_uses_style_slot(&self, mark: super::MarkKind, slot: &str) -> bool {
235 self.mark_specs().iter().any(|spec| {
236 spec.mark == mark
237 && (spec.color_slot == slot
238 || spec.true_color_slot.as_deref() == Some(slot)
239 || spec.false_color_slot.as_deref() == Some(slot)
240 || spec.highlight_color_slot.as_deref() == Some(slot))
241 })
242 }
243}
244
245impl Display for IndicatorId {
246 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
247 if let Some(registration) = catalog().get(self) {
248 return formatter.write_str(®istration.definition.display_name);
249 }
250 formatter.write_str(self.id())
251 }
252}
253
254impl Serialize for IndicatorId {
255 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
256 where
257 S: serde::Serializer,
258 {
259 serializer.serialize_str(self.id())
260 }
261}
262
263impl<'de> Deserialize<'de> for IndicatorId {
264 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
265 where
266 D: serde::Deserializer<'de>,
267 {
268 let id = String::deserialize(deserializer)?;
269 Self::parse(&id).map_err(serde::de::Error::custom)
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct IndicatorIdError(String);
275
276impl Display for IndicatorIdError {
277 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
278 formatter.write_str(&self.0)
279 }
280}
281
282impl std::error::Error for IndicatorIdError {}
283
284impl std::str::FromStr for IndicatorId {
285 type Err = IndicatorIdError;
286
287 fn from_str(id: &str) -> Result<Self, Self::Err> {
288 Self::parse(id)
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
294#[serde(transparent)]
295pub struct IndicatorConfig(serde_json::Value);
296
297impl Default for IndicatorConfig {
298 fn default() -> Self {
299 Self(serde_json::Value::Object(serde_json::Map::new()))
300 }
301}
302
303impl IndicatorConfig {
304 pub fn default_for(indicator: &IndicatorId) -> Self {
305 indicator.default_config()
306 }
307
308 pub fn from_typed<T: Serialize>(config: &T) -> Result<Self, IndicatorConfigError> {
309 serde_json::to_value(config)
310 .map(Self)
311 .map_err(|error| IndicatorConfigError::Serialize(error.to_string()))
312 }
313
314 pub fn from_value(value: serde_json::Value) -> Self {
315 Self(value)
316 }
317
318 pub fn into_value(self) -> serde_json::Value {
319 self.0
320 }
321
322 pub fn value(&self) -> &serde_json::Value {
323 &self.0
324 }
325
326 pub fn is_valid_for(&self, indicator: &IndicatorId) -> bool {
327 catalog().get(indicator).is_some_and(|registration| {
328 registration
329 .validate_config(self)
330 .inspect_err(|error| {
331 log::warn!(
332 "Rejecting indicator {} configuration: {error}",
333 indicator.id()
334 );
335 })
336 .is_ok()
337 })
338 }
339
340 pub fn normalized_for(self, indicator: &IndicatorId) -> Self {
341 indicator.normalized_config(self)
342 }
343
344 pub fn settings_for(
345 &self,
346 indicator: &IndicatorId,
347 context: IndicatorSettingsContext,
348 ) -> Result<Vec<IndicatorSetting>, IndicatorConfigError> {
349 indicator.settings(self, context)
350 }
351
352 pub fn with_setting_for(
353 self,
354 indicator: &IndicatorId,
355 id: &str,
356 value: IndicatorSettingValue,
357 ) -> Option<Self> {
358 indicator.with_setting(self, id, value)
359 }
360
361 pub fn history_requirement_for(
362 self,
363 indicator: &IndicatorId,
364 basis: Basis,
365 reference_time: UnixMs,
366 ) -> IndicatorHistoryRequirement {
367 indicator.history_requirement(self, basis, reference_time)
368 }
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub enum IndicatorConfigError {
373 MissingIndicator(IndicatorId),
374 Deserialize(String),
375 Serialize(String),
376 InvalidDefinition(IndicatorDefinitionError),
377}
378
379impl Display for IndicatorConfigError {
380 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
381 match self {
382 Self::MissingIndicator(id) => {
383 write!(formatter, "indicator {} is not installed", id.id())
384 }
385 Self::Deserialize(error) => {
386 write!(formatter, "invalid indicator configuration: {error}")
387 }
388 Self::Serialize(error) => write!(
389 formatter,
390 "could not serialize indicator configuration: {error}"
391 ),
392 Self::InvalidDefinition(error) => {
393 write!(formatter, "invalid indicator definition: {error:?}")
394 }
395 }
396 }
397}
398
399impl std::error::Error for IndicatorConfigError {}
400
401#[derive(Clone)]
402pub struct IndicatorRegistration {
403 pub id: IndicatorId,
405 pub origin: super::language::PackageOrigin,
406 pub definition: IndicatorDefinition,
408 pub(super) compiled: Arc<CompiledIndicator>,
409}
410
411impl std::fmt::Debug for IndicatorRegistration {
412 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
413 formatter
414 .debug_struct("IndicatorRegistration")
415 .field("id", &self.id)
416 .field("origin", &self.origin)
417 .field("definition", &self.definition)
418 .finish_non_exhaustive()
419 }
420}
421
422impl IndicatorRegistration {
423 pub(super) fn from_parts(
424 id: IndicatorId,
425 origin: super::language::PackageOrigin,
426 definition: IndicatorDefinition,
427 compiled: Arc<CompiledIndicator>,
428 ) -> Self {
429 Self {
430 id,
431 origin,
432 definition,
433 compiled,
434 }
435 }
436}
437
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum CatalogInstallError {
440 AlreadyInitialized,
441 Diagnostics(Vec<String>),
442}
443
444impl Display for CatalogInstallError {
445 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
446 match self {
447 Self::AlreadyInitialized => {
448 formatter.write_str("indicator catalog is already initialized")
449 }
450 Self::Diagnostics(diagnostics) => write!(formatter, "{}", diagnostics.join("\n")),
451 }
452 }
453}
454
455impl std::error::Error for CatalogInstallError {}
456
457pub(crate) fn install_catalog_with_bundled_diagnostics(
461 bundled_registrations: Vec<IndicatorRegistration>,
462 installed_registrations: Vec<IndicatorRegistration>,
463 loader_diagnostics: Vec<String>,
464) -> Result<&'static IndicatorCatalog, CatalogInstallError> {
465 let (registrations, loader_diagnostics) = merge_bundled_and_installed(
466 bundled_registrations,
467 installed_registrations,
468 loader_diagnostics,
469 );
470 let catalog =
471 IndicatorCatalog::from_registrations_with_diagnostics(registrations, loader_diagnostics);
472 for diagnostic in &catalog.diagnostics {
473 log::warn!("indicator catalog: quarantined source: {diagnostic}");
474 }
475
476 CATALOG
477 .set(catalog)
478 .map_err(|_| CatalogInstallError::AlreadyInitialized)?;
479 Ok(CATALOG.get().expect("catalog was installed"))
480}
481
482fn merge_bundled_and_installed(
483 bundled_registrations: Vec<IndicatorRegistration>,
484 installed_registrations: Vec<IndicatorRegistration>,
485 mut diagnostics: Vec<String>,
486) -> (Vec<IndicatorRegistration>, Vec<String>) {
487 let mut registrations = bundled_registrations;
488 let bundled_ids = registrations
489 .iter()
490 .map(|registration| registration.id.clone())
491 .collect::<FxHashSet<_>>();
492 let mut seen_ids = bundled_ids.clone();
493
494 for registration in installed_registrations {
495 let id = registration.id.clone();
496 if !seen_ids.insert(id.clone()) {
497 let reason = if bundled_ids.contains(&id) {
498 "it is provided by the bundled catalog"
499 } else {
500 "an earlier installed source already provides it"
501 };
502 diagnostics.push(format!(
503 "installed indicator '{}' ignored because {reason}",
504 id.id()
505 ));
506 log::warn!(
507 "indicator catalog: ignoring later installed source '{}'",
508 id.id()
509 );
510 continue;
511 }
512 registrations.push(registration);
513 }
514
515 (registrations, diagnostics)
516}
517
518#[derive(Debug)]
519pub struct IndicatorCatalog {
520 registrations: Vec<IndicatorRegistration>,
521 by_id: FxHashMap<IndicatorId, usize>,
522 diagnostics: Vec<String>,
523}
524
525impl IndicatorCatalog {
526 fn from_registrations(registrations: Vec<IndicatorRegistration>) -> Self {
528 Self::from_registrations_with_diagnostics(registrations, Vec::new())
529 }
530
531 fn from_registrations_with_diagnostics(
532 mut registrations: Vec<IndicatorRegistration>,
533 mut diagnostics: Vec<String>,
534 ) -> Self {
535 registrations.sort_by(|left, right| left.id.id().cmp(right.id.id()));
536
537 let mut valid = Vec::with_capacity(registrations.len());
538 let mut by_id = FxHashMap::default();
539 for registration in registrations {
540 if let Err(error) = IndicatorId::parse(registration.id.id()) {
541 diagnostics.push(format!(
542 "{}: invalid indicator ID: {error}",
543 registration.id.id()
544 ));
545 continue;
546 }
547 let id = registration.id.clone();
548 if by_id.contains_key(&id) {
549 diagnostics.push(format!("duplicate indicator ID: {}", registration.id.id()));
550 continue;
551 }
552 let default_config = registration.default_config();
553 if let Err(error) = registration.validate_config(&default_config) {
554 diagnostics.push(format!(
555 "{}: invalid default configuration: {error}",
556 registration.id.id()
557 ));
558 continue;
559 }
560 let index = valid.len();
561 valid.push(registration);
562 by_id.insert(id, index);
563 }
564 for diagnostic in &diagnostics {
565 log::error!("indicator catalog: {diagnostic}");
566 }
567 Self {
568 registrations: valid,
569 by_id,
570 diagnostics,
571 }
572 }
573
574 pub fn registrations(&self) -> impl ExactSizeIterator<Item = &IndicatorRegistration> + '_ {
575 self.registrations.iter()
576 }
577
578 pub fn ids(&self) -> impl ExactSizeIterator<Item = IndicatorId> + '_ {
579 self.registrations
580 .iter()
581 .map(|registration| registration.id.clone())
582 }
583
584 pub fn get(&self, id: &IndicatorId) -> Option<&IndicatorRegistration> {
585 self.by_id
586 .get(id)
587 .and_then(|index| self.registrations.get(*index))
588 }
589
590 pub fn diagnostics(&self) -> &[String] {
591 &self.diagnostics
592 }
593}
594
595pub fn all_indicators() -> Vec<IndicatorId> {
596 catalog().ids().collect()
597}
598
599pub fn indicator_definition(indicator: &IndicatorId) -> &'static IndicatorDefinition {
600 indicator.definition()
601}
602
603pub fn declared_input_requirements(indicator: &IndicatorId) -> Vec<IndicatorInputRequirement> {
604 indicator.declared_input_requirements()
605}
606
607pub fn display_name(indicator: &IndicatorId) -> &'static str {
608 &indicator.definition().display_name
609}
610
611pub fn panel_recipe(indicator: &IndicatorId) -> &'static super::IndicatorPanelRecipe {
612 indicator.default_view()
613}
614
615pub fn requires_trade_data(indicator: &IndicatorId) -> bool {
616 indicator.requires_trade_data()
617}
618
619pub fn table_columns(indicator: &IndicatorId) -> &'static [super::TableColumnSpec] {
620 match &indicator.definition().panel_recipe {
621 super::IndicatorPanelRecipe::Table { columns, .. } => columns,
622 _ => &[],
623 }
624}
625
626pub fn unsupported_reason(
627 indicator: &IndicatorId,
628 context: AvailabilityContext,
629) -> Option<IndicatorUnsupportedReason> {
630 indicator.unsupported_reason(context)
631}
632
633pub fn availability(
634 indicator: &IndicatorId,
635 context: AvailabilityContext,
636) -> IndicatorAvailability {
637 indicator.availability(context)
638}
639
640fn missing_definition() -> &'static IndicatorDefinition {
641 static MISSING_DEFINITION: OnceLock<IndicatorDefinition> = OnceLock::new();
642 MISSING_DEFINITION.get_or_init(|| {
643 IndicatorDefinition::new(
644 "Unavailable indicator",
645 Vec::new(),
646 Vec::new(),
647 super::IndicatorPanelRecipe::Plot {
648 panel_title: "Unavailable indicator".to_owned(),
649 layer_name: "Unavailable indicator".to_owned(),
650 value_precision: super::PanelValuePrecision::FixedStep(1.0),
651 value_label_policy: super::PanelValueLabelPolicy {
652 axis_mode: super::super::composition::PanelValueLabelMode::Compact,
653 header_mode: super::super::composition::PanelValueLabelMode::Commas,
654 max_decimals: None,
655 },
656 preferred_scale: super::PanelScaleMode::FitVisible,
657 layers: Vec::new(),
658 readouts: Vec::new(),
659 },
660 )
661 })
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 #[test]
669 fn ids_validate_publisher_namespaces() {
670 for id in ["community.ema-cross", "core.custom", "catalog.rsi"] {
671 let parsed = IndicatorId::parse(id).expect("valid publisher ID");
672 let serialized = serde_json::to_string(&parsed).unwrap();
673 assert_eq!(
674 serde_json::from_str::<IndicatorId>(&serialized).unwrap(),
675 parsed
676 );
677 }
678 for id in ["ema-cross", "Community.EMA"] {
679 assert!(IndicatorId::parse(id).is_err());
680 assert!(serde_json::from_str::<IndicatorId>(&format!("{id:?}")).is_err());
681 }
682 }
683
684 #[test]
685 fn duplicate_registrations_are_excluded() {
686 let scripted = super::super::language::repository_registrations()
687 .into_iter()
688 .find(|registration| registration.id.id() == "core.rsi")
689 .expect("repository RSI registration");
690 let registrations = vec![scripted.clone(), scripted];
691 let catalog = IndicatorCatalog::from_registrations(registrations);
692 assert_eq!(catalog.registrations().len(), 1);
693 assert!(
694 catalog
695 .diagnostics()
696 .iter()
697 .any(|diagnostic| diagnostic.contains("duplicate"))
698 );
699 }
700
701 #[test]
702 fn bundled_wins_and_installed_sources_keep_first_registration() {
703 let registrations = super::super::language::repository_registrations();
704 let core = registrations
705 .iter()
706 .find(|registration| registration.id.id() == "core.volume")
707 .expect("repository Volume registration")
708 .clone();
709 let rsi = registrations
710 .iter()
711 .find(|registration| registration.id.id() == "core.rsi")
712 .expect("repository RSI registration");
713 let mut first_installed = rsi.clone();
714 first_installed.origin = super::super::language::PackageOrigin::Installed;
715 first_installed.definition = IndicatorDefinition {
716 display_name: "First installed registration".to_owned(),
717 ..first_installed.definition
718 };
719 let mut later_installed = rsi.clone();
720 later_installed.origin = super::super::language::PackageOrigin::Installed;
721 later_installed.definition = IndicatorDefinition {
722 display_name: "Later installed registration".to_owned(),
723 ..later_installed.definition
724 };
725
726 let (merged, diagnostics) = merge_bundled_and_installed(
727 vec![core.clone()],
728 vec![core, first_installed, later_installed],
729 Vec::new(),
730 );
731 let catalog = IndicatorCatalog::from_registrations_with_diagnostics(merged, diagnostics);
732
733 assert_eq!(
734 catalog
735 .registrations()
736 .map(|registration| registration.id.id())
737 .collect::<Vec<_>>(),
738 vec!["core.rsi", "core.volume"]
739 );
740 assert_eq!(
741 catalog
742 .get(&IndicatorId::parse("core.volume").unwrap())
743 .expect("core Volume should be registered")
744 .definition
745 .display_name,
746 "Volume"
747 );
748 assert_eq!(
749 catalog
750 .get(&IndicatorId::parse("core.rsi").unwrap())
751 .expect("first installed RSI should be registered")
752 .definition
753 .display_name,
754 "First installed registration"
755 );
756 assert!(
757 catalog
758 .diagnostics()
759 .iter()
760 .any(|diagnostic| diagnostic.contains("provided by the bundled catalog"))
761 );
762 assert!(
763 catalog
764 .diagnostics()
765 .iter()
766 .any(|diagnostic| diagnostic
767 .contains("earlier installed source already provides it"))
768 );
769 }
770}