1pub mod verify;
16
17use std::collections::{BTreeMap, BTreeSet};
18
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22#[derive(Debug, Clone, Deserialize, Serialize)]
24pub struct Catalog {
25 pub last_updated: String,
26 #[serde(default)]
28 pub provider: Option<Value>,
29 #[serde(default)]
31 pub category_filter: Option<Value>,
32 #[serde(default)]
34 pub provider_filter: Option<Value>,
35 #[serde(default)]
36 pub source: Option<Value>,
37 pub services: Vec<ServiceDetail>,
38 #[serde(flatten)]
39 pub extra: BTreeMap<String, Value>,
40}
41
42impl Catalog {
43 pub fn from_json_envelope(raw: &str) -> Result<Self, serde_json::Error> {
46 #[derive(Deserialize)]
47 struct Envelope {
48 data: Catalog,
49 }
50 Ok(serde_json::from_str::<Envelope>(raw)?.data)
51 }
52
53 pub fn lookup(&self, reference: &str) -> Option<&ServiceDetail> {
56 self.services.iter().find(|s| s.reference() == reference)
57 }
58
59 pub fn drift_report(&self) -> Vec<String> {
62 let mut out = Vec::new();
63 push_extra(&mut out, "catalog", &self.extra);
64 for service in &self.services {
65 service.collect_drift(&mut out);
66 }
67 out
68 }
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize)]
72pub struct ServiceDetail {
73 pub id: String,
74 pub object: String,
75 pub provider_id: String,
76 pub provider_name: String,
77 pub service_id: String,
78 #[serde(default)]
79 pub categories: Vec<Category>,
80 pub kind: Kind,
81 pub scope: Scope,
82 pub availability: Availability,
83 #[serde(default)]
84 pub group: Option<String>,
85 #[serde(default)]
86 pub description: Option<String>,
87 #[serde(default)]
88 pub llm_context: Option<String>,
89 #[serde(default)]
90 pub created: Option<Value>,
91 pub development: bool,
92 pub livemode: bool,
93 #[serde(default)]
94 pub allowed_updates: Vec<AllowedUpdate>,
95 #[serde(default)]
96 pub updateable_to: Vec<String>,
97 #[serde(default)]
98 pub constraints: Vec<Constraint>,
99 pub pricing: Pricing,
100 #[serde(default)]
101 pub configuration_schema: Option<ConfigSchema>,
102 #[serde(default)]
103 pub provider_configuration_schema: Option<ConfigSchema>,
104 #[serde(flatten)]
105 pub extra: BTreeMap<String, Value>,
106}
107
108impl ServiceDetail {
109 pub fn reference(&self) -> String {
111 format!(
112 "{}/{}",
113 self.provider_name.to_ascii_lowercase(),
114 self.service_id
115 )
116 }
117
118 pub fn validate_config(&self, config: &Value) -> Result<(), Vec<String>> {
122 let mut violations = Vec::new();
123 let object = match config.as_object() {
124 Some(map) => map,
125 None => {
126 violations.push("config is not a JSON object".to_owned());
127 return Err(violations);
128 }
129 };
130
131 let schema = self.configuration_schema.as_ref();
132 let selectors = self.pricing.selector_keys();
133
134 if let Some(schema) = schema {
136 for name in &schema.required {
137 if !object.contains_key(name) {
138 violations.push(format!("missing required field `{name}`"));
139 }
140 }
141 }
142
143 let allow_extra = schema
144 .and_then(|s| s.additional_properties)
145 .unwrap_or(false);
146
147 for (key, value) in object {
148 if let Some(property) = schema.and_then(|s| s.properties.get(key)) {
149 violations.extend(
150 property
151 .validate_value(value)
152 .into_iter()
153 .map(|detail| format!("`{key}`: {detail}")),
154 );
155 } else if selectors.contains(key.as_str()) {
156 let allowed = self.pricing.selector_values(key);
157 if !allowed.iter().any(|candidate| candidate == value) {
158 let rendered: Vec<String> = allowed.iter().map(value_label).collect();
159 violations.push(format!(
160 "`{key}`: {} is not an allowed tier value (expected one of [{}])",
161 value_label(value),
162 rendered.join(", ")
163 ));
164 }
165 } else if !allow_extra {
166 violations.push(format!(
167 "unknown field `{key}` (not in schema or pricing tiers)"
168 ));
169 }
170 }
171
172 if violations.is_empty() {
173 Ok(())
174 } else {
175 Err(violations)
176 }
177 }
178
179 pub fn requires_confirmation(&self, config: &Value) -> bool {
183 self.requires_confirmation_with_paid(config, false)
184 }
185
186 pub fn requires_confirmation_with_paid(&self, config: &Value, confirm_paid: bool) -> bool {
189 self.pricing
190 .requires_confirmation_with_paid(config, confirm_paid)
191 }
192
193 pub fn required_parent_services(&self, config: &Value, prefer_paid: bool) -> Vec<String> {
197 self.pricing.required_parent_services(config, prefer_paid)
198 }
199
200 fn collect_drift(&self, out: &mut Vec<String>) {
201 let at = self.reference();
202 push_extra(out, &at, &self.extra);
203 push_unknown(out, &at, "kind", self.kind == Kind::Unknown);
204 push_unknown(out, &at, "scope", self.scope == Scope::Unknown);
205 push_unknown(
206 out,
207 &at,
208 "availability",
209 self.availability == Availability::Unknown,
210 );
211 for (i, category) in self.categories.iter().enumerate() {
212 push_unknown(
213 out,
214 &at,
215 &format!("categories[{i}]"),
216 *category == Category::Unknown,
217 );
218 }
219 for (i, update) in self.allowed_updates.iter().enumerate() {
220 let path = format!("{at}.allowed_updates[{i}]");
221 push_extra(out, &path, &update.extra);
222 push_unknown(
223 out,
224 &path,
225 "direction",
226 update.direction == Direction::Unknown,
227 );
228 }
229 for (i, constraint) in self.constraints.iter().enumerate() {
230 constraint.collect_drift(out, &format!("{at}.constraints[{i}]"));
231 }
232 self.pricing.collect_drift(out, &at);
233 if let Some(schema) = &self.configuration_schema {
234 schema.collect_drift(out, &format!("{at}.configuration_schema"));
235 }
236 if let Some(schema) = &self.provider_configuration_schema {
237 schema.collect_drift(out, &format!("{at}.provider_configuration_schema"));
238 }
239 }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
243#[serde(rename_all = "snake_case")]
244pub enum Kind {
245 Deployable,
246 Plan,
247 #[serde(other)]
248 Unknown,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
252#[serde(rename_all = "snake_case")]
253pub enum Scope {
254 Account,
255 Project,
256 #[serde(other)]
257 Unknown,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
261#[serde(rename_all = "snake_case")]
262pub enum Availability {
263 Available,
264 NotInCountry,
265 #[serde(other)]
266 Unknown,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
270#[serde(rename_all = "snake_case")]
271pub enum Category {
272 Ai,
273 Analytics,
274 Auth,
275 Browser,
276 Cache,
277 Cdn,
278 Ci,
279 Communications,
280 Compute,
281 Database,
282 Domains,
283 Ecommerce,
284 Email,
285 FeatureFlags,
286 Messaging,
287 Notification,
288 Observability,
289 Payments,
290 Queue,
291 Sandbox,
292 Search,
293 Storage,
294 #[serde(other)]
295 Unknown,
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
299#[serde(rename_all = "lowercase")]
300pub enum Direction {
301 Any,
302 Up,
303 Down,
304 #[serde(other)]
305 Unknown,
306}
307
308#[derive(Debug, Clone, Deserialize, Serialize)]
309pub struct AllowedUpdate {
310 pub direction: Direction,
311 pub service: String,
312 #[serde(flatten)]
313 pub extra: BTreeMap<String, Value>,
314}
315
316#[derive(Debug, Clone, Deserialize, Serialize)]
317#[serde(tag = "type", rename_all = "snake_case")]
318pub enum Constraint {
319 Count {
320 count: CountConstraint,
321 },
322 MutualExclusionAllowedUpdates {
323 mutual_exclusion_allowed_updates: bool,
324 },
325 #[serde(other)]
326 Unknown,
327}
328
329impl Constraint {
330 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
331 match self {
332 Self::Count { count } => push_extra(out, &format!("{at}.count"), &count.extra),
333 Self::MutualExclusionAllowedUpdates { .. } => {}
334 Self::Unknown => out.push(format!("{at}: unknown constraint type")),
335 }
336 }
337}
338
339#[derive(Debug, Clone, Deserialize, Serialize)]
340pub struct CountConstraint {
341 pub at_most: i64,
342 #[serde(flatten)]
343 pub extra: BTreeMap<String, Value>,
344}
345
346#[derive(Debug, Clone, Deserialize, Serialize)]
347pub struct Pricing {
348 #[serde(rename = "type")]
349 pub kind: PricingKind,
350 #[serde(default)]
351 pub paid: Option<PaidPricing>,
352 #[serde(default)]
353 pub paid_pricing: Vec<PaidPricingEntry>,
354 #[serde(default)]
355 pub component: Option<ComponentPricing>,
356 #[serde(flatten)]
357 pub extra: BTreeMap<String, Value>,
358}
359
360impl Pricing {
361 pub fn selector_keys(&self) -> BTreeSet<String> {
364 let mut keys = BTreeSet::new();
365 for entry in &self.paid_pricing {
366 if let Some(Value::Object(map)) = &entry.configuration {
367 keys.extend(map.keys().cloned());
368 }
369 }
370 keys
371 }
372
373 pub fn selector_values(&self, key: &str) -> Vec<Value> {
375 let mut values = Vec::new();
376 for entry in &self.paid_pricing {
377 if let Some(Value::Object(map)) = &entry.configuration
378 && let Some(value) = map.get(key)
379 && !values.contains(value)
380 {
381 values.push(value.clone());
382 }
383 }
384 values
385 }
386
387 pub fn match_tier(&self, config: &Value) -> Option<&PaidPricingEntry> {
390 let object = config.as_object();
391 let mut default = None;
392 for entry in &self.paid_pricing {
393 if entry.is_default == Some(true) {
394 default = Some(entry);
395 }
396 if let Some(Value::Object(tier)) = &entry.configuration {
397 let satisfied = tier
398 .iter()
399 .all(|(k, v)| object.and_then(|o| o.get(k)) == Some(v));
400 if satisfied && !tier.is_empty() {
401 return Some(entry);
402 }
403 }
404 }
405 default
406 }
407
408 pub fn requires_confirmation(&self, config: &Value) -> bool {
410 self.requires_confirmation_with_paid(config, false)
411 }
412
413 pub fn requires_confirmation_with_paid(&self, config: &Value, confirm_paid: bool) -> bool {
416 if let Some(component) = &self.component {
417 return component
418 .match_option(confirm_paid)
419 .is_some_and(|option| option.kind == ComponentOptionKind::Paid);
420 }
421 if !self.paid_pricing.is_empty()
422 && self.paid_pricing.iter().any(|e| e.configuration.is_some())
423 {
424 return match self.match_tier(config) {
425 Some(tier) => tier.kind != PaidKind::Free,
426 None => self.kind == PricingKind::Paid,
427 };
428 }
429 self.kind == PricingKind::Paid
430 }
431
432 pub fn required_parent_services(&self, _config: &Value, prefer_paid: bool) -> Vec<String> {
436 let Some(component) = &self.component else {
437 return Vec::new();
438 };
439 let Some(option) = component.match_option(prefer_paid) else {
440 return Vec::new();
441 };
442 if option.parent_services.len() == 1 {
443 option.parent_services.clone()
444 } else {
445 Vec::new()
446 }
447 }
448
449 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
450 let path = format!("{at}.pricing");
451 push_extra(out, &path, &self.extra);
452 push_unknown(out, &path, "type", self.kind == PricingKind::Unknown);
453 if let Some(paid) = &self.paid {
454 paid.collect_drift(out, &format!("{path}.paid"));
455 }
456 for (i, entry) in self.paid_pricing.iter().enumerate() {
457 entry.collect_drift(out, &format!("{path}.paid_pricing[{i}]"));
458 }
459 if let Some(component) = &self.component {
460 component.collect_drift(out, &format!("{path}.component"));
461 }
462 }
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
466#[serde(rename_all = "snake_case")]
467pub enum PricingKind {
468 Free,
469 Paid,
470 Component,
471 #[serde(other)]
472 Unknown,
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
476#[serde(rename_all = "snake_case")]
477pub enum PaidKind {
478 Free,
479 Freeform,
480 #[serde(other)]
481 Unknown,
482}
483
484#[derive(Debug, Clone, Deserialize, Serialize)]
485pub struct PaidPricing {
486 #[serde(default)]
487 pub description: Option<String>,
488 #[serde(default)]
489 pub freeform: Option<String>,
490 #[serde(rename = "type")]
491 pub kind: PaidKind,
492 #[serde(flatten)]
493 pub extra: BTreeMap<String, Value>,
494}
495
496impl PaidPricing {
497 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
498 push_extra(out, at, &self.extra);
499 push_unknown(out, at, "type", self.kind == PaidKind::Unknown);
500 }
501}
502
503#[derive(Debug, Clone, Deserialize, Serialize)]
504pub struct PaidPricingEntry {
505 #[serde(default)]
506 pub configuration: Option<Value>,
507 #[serde(default)]
508 pub description: Option<String>,
509 #[serde(default)]
510 pub freeform: Option<String>,
511 #[serde(default)]
512 pub is_default: Option<bool>,
513 #[serde(rename = "type")]
514 pub kind: PaidKind,
515 #[serde(flatten)]
516 pub extra: BTreeMap<String, Value>,
517}
518
519impl PaidPricingEntry {
520 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
521 push_extra(out, at, &self.extra);
522 push_unknown(out, at, "type", self.kind == PaidKind::Unknown);
523 }
524}
525
526#[derive(Debug, Clone, Deserialize, Serialize)]
527pub struct ComponentPricing {
528 #[serde(default)]
529 pub options: Vec<ComponentOption>,
530 #[serde(flatten)]
531 pub extra: BTreeMap<String, Value>,
532}
533
534impl ComponentPricing {
535 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
536 push_extra(out, at, &self.extra);
537 for (i, option) in self.options.iter().enumerate() {
538 option.collect_drift(out, &format!("{at}.options[{i}]"));
539 }
540 }
541
542 fn match_option(&self, prefer_paid: bool) -> Option<&ComponentOption> {
545 if prefer_paid {
546 for option in &self.options {
547 if option.is_default == Some(true) && option.kind == ComponentOptionKind::Paid {
548 return Some(option);
549 }
550 }
551 return self
552 .options
553 .iter()
554 .find(|option| option.kind == ComponentOptionKind::Paid)
555 .or_else(|| self.options.first());
556 }
557 for option in &self.options {
558 if option.is_default == Some(true) && option.kind == ComponentOptionKind::Free {
559 return Some(option);
560 }
561 }
562 self.options
563 .iter()
564 .find(|option| option.kind == ComponentOptionKind::Free)
565 .or_else(|| self.options.first())
566 }
567}
568
569#[derive(Debug, Clone, Deserialize, Serialize)]
570pub struct ComponentOption {
571 #[serde(default)]
572 pub is_default: Option<bool>,
573 #[serde(default)]
574 pub paid: Option<PaidPricing>,
575 #[serde(default)]
576 pub parent_services: Vec<String>,
577 #[serde(rename = "type")]
578 pub kind: ComponentOptionKind,
579 #[serde(flatten)]
580 pub extra: BTreeMap<String, Value>,
581}
582
583impl ComponentOption {
584 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
585 push_extra(out, at, &self.extra);
586 push_unknown(out, at, "type", self.kind == ComponentOptionKind::Unknown);
587 if let Some(paid) = &self.paid {
588 paid.collect_drift(out, &format!("{at}.paid"));
589 }
590 }
591}
592
593#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
594#[serde(rename_all = "snake_case")]
595pub enum ComponentOptionKind {
596 Free,
597 Paid,
598 #[serde(other)]
599 Unknown,
600}
601
602#[derive(Debug, Clone, Default, Deserialize, Serialize)]
605pub struct ConfigSchema {
606 #[serde(rename = "type", default)]
607 pub schema_type: Option<String>,
608 #[serde(default)]
609 pub title: Option<String>,
610 #[serde(rename = "additionalProperties", default)]
611 pub additional_properties: Option<bool>,
612 #[serde(default)]
613 pub required: Vec<String>,
614 #[serde(default)]
615 pub optional: Vec<String>,
616 #[serde(default)]
617 pub properties: BTreeMap<String, PropertySchema>,
618 #[serde(flatten)]
619 pub extra: BTreeMap<String, Value>,
620}
621
622impl ConfigSchema {
623 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
624 push_extra(out, at, &self.extra);
625 for (name, property) in &self.properties {
626 property.collect_drift(out, &format!("{at}.properties.{name}"));
627 }
628 }
629}
630
631#[derive(Debug, Clone, Deserialize, Serialize)]
632pub struct PropertySchema {
633 #[serde(rename = "type")]
634 pub prop_type: PropertyType,
635 #[serde(default)]
636 pub description: Option<String>,
637 #[serde(default)]
638 pub title: Option<String>,
639 #[serde(default)]
640 pub default: Option<Value>,
641 #[serde(rename = "enum", default)]
642 pub allowed: Vec<Value>,
643 #[serde(rename = "minLength", default)]
644 pub min_length: Option<i64>,
645 #[serde(rename = "maxLength", default)]
646 pub max_length: Option<i64>,
647 #[serde(default)]
648 pub minimum: Option<f64>,
649 #[serde(default)]
650 pub maximum: Option<f64>,
651 #[serde(rename = "multipleOf", default)]
652 pub multiple_of: Option<f64>,
653 #[serde(default)]
656 pub pattern: Option<String>,
657 #[serde(flatten)]
658 pub extra: BTreeMap<String, Value>,
659}
660
661impl PropertySchema {
662 pub fn validate_value(&self, value: &Value) -> Vec<String> {
665 let mut out = Vec::new();
666 let type_ok = match self.prop_type {
667 PropertyType::String => value.is_string(),
668 PropertyType::Integer => value.is_i64() || value.is_u64(),
669 PropertyType::Number => value.is_number(),
670 PropertyType::Boolean => value.is_boolean(),
671 PropertyType::Unknown => true,
672 };
673 if !type_ok {
674 out.push(format!(
675 "expected {:?}, got {}",
676 self.prop_type,
677 value_label(value)
678 ));
679 return out;
681 }
682 if !self.allowed.is_empty() && !self.allowed.iter().any(|candidate| candidate == value) {
683 let rendered: Vec<String> = self.allowed.iter().map(value_label).collect();
684 out.push(format!(
685 "{} is not in enum [{}]",
686 value_label(value),
687 rendered.join(", ")
688 ));
689 }
690 if let Some(text) = value.as_str() {
691 let len = text.chars().count() as i64;
692 if let Some(min) = self.min_length
693 && len < min
694 {
695 out.push(format!("length {len} < minLength {min}"));
696 }
697 if let Some(max) = self.max_length
698 && len > max
699 {
700 out.push(format!("length {len} > maxLength {max}"));
701 }
702 }
703 if let Some(number) = value.as_f64() {
704 if let Some(min) = self.minimum
705 && number < min
706 {
707 out.push(format!("{number} < minimum {min}"));
708 }
709 if let Some(max) = self.maximum
710 && number > max
711 {
712 out.push(format!("{number} > maximum {max}"));
713 }
714 if let Some(step) = self.multiple_of
715 && step != 0.0
716 && (number / step).fract().abs() > f64::EPSILON
717 {
718 out.push(format!("{number} is not a multiple of {step}"));
719 }
720 }
721 out
722 }
723
724 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
725 push_extra(out, at, &self.extra);
726 push_unknown(out, at, "type", self.prop_type == PropertyType::Unknown);
727 }
728}
729
730#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
731#[serde(rename_all = "lowercase")]
732pub enum PropertyType {
733 String,
734 Integer,
735 Number,
736 Boolean,
737 #[serde(other)]
738 Unknown,
739}
740
741fn push_extra(out: &mut Vec<String>, at: &str, extra: &BTreeMap<String, Value>) {
742 for key in extra.keys() {
743 out.push(format!("{at}: unmodeled field `{key}`"));
744 }
745}
746
747fn push_unknown(out: &mut Vec<String>, at: &str, field: &str, is_unknown: bool) {
748 if is_unknown {
749 out.push(format!("{at}.{field}: unknown enum value"));
750 }
751}
752
753fn value_label(value: &Value) -> String {
754 match value {
755 Value::String(s) => format!("\"{s}\""),
756 other => other.to_string(),
757 }
758}
759
760#[cfg(test)]
761mod tests;