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)]
27 pub provider: Option<String>,
28 #[serde(default)]
29 pub category_filter: Option<String>,
30 #[serde(default)]
31 pub provider_filter: Option<String>,
32 #[serde(default)]
33 pub source: Option<String>,
34 pub services: Vec<ServiceDetail>,
35 #[serde(flatten)]
36 pub extra: BTreeMap<String, Value>,
37}
38
39impl Catalog {
40 pub fn from_json_envelope(raw: &str) -> Result<Self, serde_json::Error> {
43 #[derive(Deserialize)]
44 struct Envelope {
45 data: Catalog,
46 }
47 Ok(serde_json::from_str::<Envelope>(raw)?.data)
48 }
49
50 pub fn lookup(&self, reference: &str) -> Option<&ServiceDetail> {
53 self.services.iter().find(|s| s.reference() == reference)
54 }
55
56 pub fn drift_report(&self) -> Vec<String> {
59 let mut out = Vec::new();
60 push_extra(&mut out, "catalog", &self.extra);
61 for service in &self.services {
62 service.collect_drift(&mut out);
63 }
64 out
65 }
66}
67
68#[derive(Debug, Clone, Deserialize, Serialize)]
69pub struct ServiceDetail {
70 pub id: String,
71 pub object: String,
72 pub provider_id: String,
73 pub provider_name: String,
74 pub service_id: String,
75 #[serde(default)]
76 pub categories: Vec<Category>,
77 pub kind: Kind,
78 pub scope: Scope,
79 pub availability: Availability,
80 #[serde(default)]
81 pub group: Option<String>,
82 #[serde(default)]
83 pub description: Option<String>,
84 #[serde(default)]
85 pub llm_context: Option<String>,
86 #[serde(default)]
87 pub created: Option<Value>,
88 pub development: bool,
89 pub livemode: bool,
90 #[serde(default)]
91 pub allowed_updates: Vec<AllowedUpdate>,
92 #[serde(default)]
93 pub updateable_to: Vec<String>,
94 #[serde(default)]
95 pub constraints: Vec<Constraint>,
96 pub pricing: Pricing,
97 #[serde(default)]
98 pub configuration_schema: Option<ConfigSchema>,
99 #[serde(default)]
100 pub provider_configuration_schema: Option<ConfigSchema>,
101 #[serde(flatten)]
102 pub extra: BTreeMap<String, Value>,
103}
104
105impl ServiceDetail {
106 pub fn reference(&self) -> String {
108 format!(
109 "{}/{}",
110 self.provider_name.to_ascii_lowercase(),
111 self.service_id
112 )
113 }
114
115 pub fn validate_config(&self, config: &Value) -> Result<(), Vec<String>> {
119 let mut violations = Vec::new();
120 let object = match config.as_object() {
121 Some(map) => map,
122 None => {
123 violations.push("config is not a JSON object".to_owned());
124 return Err(violations);
125 }
126 };
127
128 let schema = self.configuration_schema.as_ref();
129 let selectors = self.pricing.selector_keys();
130
131 if let Some(schema) = schema {
133 for name in &schema.required {
134 if !object.contains_key(name) {
135 violations.push(format!("missing required field `{name}`"));
136 }
137 }
138 }
139
140 let allow_extra = schema
141 .and_then(|s| s.additional_properties)
142 .unwrap_or(false);
143
144 for (key, value) in object {
145 if let Some(property) = schema.and_then(|s| s.properties.get(key)) {
146 violations.extend(
147 property
148 .validate_value(value)
149 .into_iter()
150 .map(|detail| format!("`{key}`: {detail}")),
151 );
152 } else if selectors.contains(key.as_str()) {
153 let allowed = self.pricing.selector_values(key);
154 if !allowed.iter().any(|candidate| candidate == value) {
155 let rendered: Vec<String> = allowed.iter().map(value_label).collect();
156 violations.push(format!(
157 "`{key}`: {} is not an allowed tier value (expected one of [{}])",
158 value_label(value),
159 rendered.join(", ")
160 ));
161 }
162 } else if !allow_extra {
163 violations.push(format!(
164 "unknown field `{key}` (not in schema or pricing tiers)"
165 ));
166 }
167 }
168
169 if violations.is_empty() {
170 Ok(())
171 } else {
172 Err(violations)
173 }
174 }
175
176 pub fn requires_confirmation(&self, config: &Value) -> bool {
180 self.requires_confirmation_with_paid(config, false)
181 }
182
183 pub fn requires_confirmation_with_paid(&self, config: &Value, confirm_paid: bool) -> bool {
186 self.pricing
187 .requires_confirmation_with_paid(config, confirm_paid)
188 }
189
190 pub fn required_parent_services(&self, config: &Value, prefer_paid: bool) -> Vec<String> {
194 self.pricing.required_parent_services(config, prefer_paid)
195 }
196
197 fn collect_drift(&self, out: &mut Vec<String>) {
198 let at = self.reference();
199 push_extra(out, &at, &self.extra);
200 push_unknown(out, &at, "kind", self.kind == Kind::Unknown);
201 push_unknown(out, &at, "scope", self.scope == Scope::Unknown);
202 push_unknown(
203 out,
204 &at,
205 "availability",
206 self.availability == Availability::Unknown,
207 );
208 for (i, category) in self.categories.iter().enumerate() {
209 push_unknown(
210 out,
211 &at,
212 &format!("categories[{i}]"),
213 *category == Category::Unknown,
214 );
215 }
216 for (i, update) in self.allowed_updates.iter().enumerate() {
217 let path = format!("{at}.allowed_updates[{i}]");
218 push_extra(out, &path, &update.extra);
219 push_unknown(
220 out,
221 &path,
222 "direction",
223 update.direction == Direction::Unknown,
224 );
225 }
226 for (i, constraint) in self.constraints.iter().enumerate() {
227 constraint.collect_drift(out, &format!("{at}.constraints[{i}]"));
228 }
229 self.pricing.collect_drift(out, &at);
230 if let Some(schema) = &self.configuration_schema {
231 schema.collect_drift(out, &format!("{at}.configuration_schema"));
232 }
233 if let Some(schema) = &self.provider_configuration_schema {
234 schema.collect_drift(out, &format!("{at}.provider_configuration_schema"));
235 }
236 }
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
240#[serde(rename_all = "snake_case")]
241pub enum Kind {
242 Deployable,
243 Plan,
244 #[serde(other)]
245 Unknown,
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
249#[serde(rename_all = "snake_case")]
250pub enum Scope {
251 Account,
252 Project,
253 #[serde(other)]
254 Unknown,
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
258#[serde(rename_all = "snake_case")]
259pub enum Availability {
260 Available,
261 NotInCountry,
262 #[serde(other)]
263 Unknown,
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
267#[serde(rename_all = "snake_case")]
268pub enum Category {
269 Ai,
270 Analytics,
271 Auth,
272 Browser,
273 Cache,
274 Cdn,
275 Ci,
276 Communications,
277 Compute,
278 Database,
279 Domains,
280 Ecommerce,
281 Email,
282 FeatureFlags,
283 Messaging,
284 Notification,
285 Observability,
286 Payments,
287 Queue,
288 Sandbox,
289 Search,
290 Storage,
291 #[serde(other)]
292 Unknown,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
296#[serde(rename_all = "lowercase")]
297pub enum Direction {
298 Any,
299 Up,
300 Down,
301 #[serde(other)]
302 Unknown,
303}
304
305#[derive(Debug, Clone, Deserialize, Serialize)]
306pub struct AllowedUpdate {
307 pub direction: Direction,
308 pub service: String,
309 #[serde(flatten)]
310 pub extra: BTreeMap<String, Value>,
311}
312
313#[derive(Debug, Clone, Deserialize, Serialize)]
314#[serde(tag = "type", rename_all = "snake_case")]
315pub enum Constraint {
316 Count {
317 count: CountConstraint,
318 },
319 MutualExclusionAllowedUpdates {
320 mutual_exclusion_allowed_updates: bool,
321 },
322 #[serde(other)]
323 Unknown,
324}
325
326impl Constraint {
327 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
328 match self {
329 Self::Count { count } => push_extra(out, &format!("{at}.count"), &count.extra),
330 Self::MutualExclusionAllowedUpdates { .. } => {}
331 Self::Unknown => out.push(format!("{at}: unknown constraint type")),
332 }
333 }
334}
335
336#[derive(Debug, Clone, Deserialize, Serialize)]
337pub struct CountConstraint {
338 pub at_most: i64,
339 #[serde(flatten)]
340 pub extra: BTreeMap<String, Value>,
341}
342
343#[derive(Debug, Clone, Deserialize, Serialize)]
344pub struct Pricing {
345 #[serde(rename = "type")]
346 pub kind: PricingKind,
347 #[serde(default)]
348 pub paid: Option<PaidPricing>,
349 #[serde(default)]
350 pub paid_pricing: Vec<PaidPricingEntry>,
351 #[serde(default)]
352 pub component: Option<ComponentPricing>,
353 #[serde(flatten)]
354 pub extra: BTreeMap<String, Value>,
355}
356
357impl Pricing {
358 pub fn selector_keys(&self) -> BTreeSet<String> {
361 let mut keys = BTreeSet::new();
362 for entry in &self.paid_pricing {
363 if let Some(Value::Object(map)) = &entry.configuration {
364 keys.extend(map.keys().cloned());
365 }
366 }
367 keys
368 }
369
370 pub fn selector_values(&self, key: &str) -> Vec<Value> {
372 let mut values = Vec::new();
373 for entry in &self.paid_pricing {
374 if let Some(Value::Object(map)) = &entry.configuration
375 && let Some(value) = map.get(key)
376 && !values.contains(value)
377 {
378 values.push(value.clone());
379 }
380 }
381 values
382 }
383
384 pub fn match_tier(&self, config: &Value) -> Option<&PaidPricingEntry> {
387 let object = config.as_object();
388 let mut default = None;
389 for entry in &self.paid_pricing {
390 if entry.is_default == Some(true) {
391 default = Some(entry);
392 }
393 if let Some(Value::Object(tier)) = &entry.configuration {
394 let satisfied = tier
395 .iter()
396 .all(|(k, v)| object.and_then(|o| o.get(k)) == Some(v));
397 if satisfied && !tier.is_empty() {
398 return Some(entry);
399 }
400 }
401 }
402 default
403 }
404
405 pub fn requires_confirmation(&self, config: &Value) -> bool {
407 self.requires_confirmation_with_paid(config, false)
408 }
409
410 pub fn requires_confirmation_with_paid(&self, config: &Value, confirm_paid: bool) -> bool {
413 if let Some(component) = &self.component {
414 return component
415 .match_option(confirm_paid)
416 .is_some_and(|option| option.kind == ComponentOptionKind::Paid);
417 }
418 if !self.paid_pricing.is_empty()
419 && self.paid_pricing.iter().any(|e| e.configuration.is_some())
420 {
421 return match self.match_tier(config) {
422 Some(tier) => tier.kind != PaidKind::Free,
423 None => self.kind == PricingKind::Paid,
424 };
425 }
426 self.kind == PricingKind::Paid
427 }
428
429 pub fn required_parent_services(&self, _config: &Value, prefer_paid: bool) -> Vec<String> {
433 let Some(component) = &self.component else {
434 return Vec::new();
435 };
436 let Some(option) = component.match_option(prefer_paid) else {
437 return Vec::new();
438 };
439 if option.parent_services.len() == 1 {
440 option.parent_services.clone()
441 } else {
442 Vec::new()
443 }
444 }
445
446 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
447 let path = format!("{at}.pricing");
448 push_extra(out, &path, &self.extra);
449 push_unknown(out, &path, "type", self.kind == PricingKind::Unknown);
450 if let Some(paid) = &self.paid {
451 paid.collect_drift(out, &format!("{path}.paid"));
452 }
453 for (i, entry) in self.paid_pricing.iter().enumerate() {
454 entry.collect_drift(out, &format!("{path}.paid_pricing[{i}]"));
455 }
456 if let Some(component) = &self.component {
457 component.collect_drift(out, &format!("{path}.component"));
458 }
459 }
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
463#[serde(rename_all = "snake_case")]
464pub enum PricingKind {
465 Free,
466 Paid,
467 Component,
468 #[serde(other)]
469 Unknown,
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
473#[serde(rename_all = "snake_case")]
474pub enum PaidKind {
475 Free,
476 Freeform,
477 #[serde(other)]
478 Unknown,
479}
480
481#[derive(Debug, Clone, Deserialize, Serialize)]
482pub struct PaidPricing {
483 #[serde(default)]
484 pub description: Option<String>,
485 #[serde(default)]
486 pub freeform: Option<String>,
487 #[serde(rename = "type")]
488 pub kind: PaidKind,
489 #[serde(flatten)]
490 pub extra: BTreeMap<String, Value>,
491}
492
493impl PaidPricing {
494 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
495 push_extra(out, at, &self.extra);
496 push_unknown(out, at, "type", self.kind == PaidKind::Unknown);
497 }
498}
499
500#[derive(Debug, Clone, Deserialize, Serialize)]
501pub struct PaidPricingEntry {
502 #[serde(default)]
503 pub configuration: Option<Value>,
504 #[serde(default)]
505 pub description: Option<String>,
506 #[serde(default)]
507 pub freeform: Option<String>,
508 #[serde(default)]
509 pub is_default: Option<bool>,
510 #[serde(rename = "type")]
511 pub kind: PaidKind,
512 #[serde(flatten)]
513 pub extra: BTreeMap<String, Value>,
514}
515
516impl PaidPricingEntry {
517 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
518 push_extra(out, at, &self.extra);
519 push_unknown(out, at, "type", self.kind == PaidKind::Unknown);
520 }
521}
522
523#[derive(Debug, Clone, Deserialize, Serialize)]
524pub struct ComponentPricing {
525 #[serde(default)]
526 pub options: Vec<ComponentOption>,
527 #[serde(flatten)]
528 pub extra: BTreeMap<String, Value>,
529}
530
531impl ComponentPricing {
532 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
533 push_extra(out, at, &self.extra);
534 for (i, option) in self.options.iter().enumerate() {
535 option.collect_drift(out, &format!("{at}.options[{i}]"));
536 }
537 }
538
539 fn match_option(&self, prefer_paid: bool) -> Option<&ComponentOption> {
542 if prefer_paid {
543 for option in &self.options {
544 if option.is_default == Some(true) && option.kind == ComponentOptionKind::Paid {
545 return Some(option);
546 }
547 }
548 return self
549 .options
550 .iter()
551 .find(|option| option.kind == ComponentOptionKind::Paid)
552 .or_else(|| self.options.first());
553 }
554 for option in &self.options {
555 if option.is_default == Some(true) && option.kind == ComponentOptionKind::Free {
556 return Some(option);
557 }
558 }
559 self.options
560 .iter()
561 .find(|option| option.kind == ComponentOptionKind::Free)
562 .or_else(|| self.options.first())
563 }
564}
565
566#[derive(Debug, Clone, Deserialize, Serialize)]
567pub struct ComponentOption {
568 #[serde(default)]
569 pub is_default: Option<bool>,
570 #[serde(default)]
571 pub paid: Option<PaidPricing>,
572 #[serde(default)]
573 pub parent_services: Vec<String>,
574 #[serde(rename = "type")]
575 pub kind: ComponentOptionKind,
576 #[serde(flatten)]
577 pub extra: BTreeMap<String, Value>,
578}
579
580impl ComponentOption {
581 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
582 push_extra(out, at, &self.extra);
583 push_unknown(out, at, "type", self.kind == ComponentOptionKind::Unknown);
584 if let Some(paid) = &self.paid {
585 paid.collect_drift(out, &format!("{at}.paid"));
586 }
587 }
588}
589
590#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
591#[serde(rename_all = "snake_case")]
592pub enum ComponentOptionKind {
593 Free,
594 Paid,
595 #[serde(other)]
596 Unknown,
597}
598
599#[derive(Debug, Clone, Default, Deserialize, Serialize)]
602pub struct ConfigSchema {
603 #[serde(rename = "type", default)]
604 pub schema_type: Option<String>,
605 #[serde(default)]
606 pub title: Option<String>,
607 #[serde(rename = "additionalProperties", default)]
608 pub additional_properties: Option<bool>,
609 #[serde(default)]
610 pub required: Vec<String>,
611 #[serde(default)]
612 pub properties: BTreeMap<String, PropertySchema>,
613 #[serde(flatten)]
614 pub extra: BTreeMap<String, Value>,
615}
616
617impl ConfigSchema {
618 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
619 push_extra(out, at, &self.extra);
620 for (name, property) in &self.properties {
621 property.collect_drift(out, &format!("{at}.properties.{name}"));
622 }
623 }
624}
625
626#[derive(Debug, Clone, Deserialize, Serialize)]
627pub struct PropertySchema {
628 #[serde(rename = "type")]
629 pub prop_type: PropertyType,
630 #[serde(default)]
631 pub description: Option<String>,
632 #[serde(default)]
633 pub title: Option<String>,
634 #[serde(default)]
635 pub default: Option<Value>,
636 #[serde(rename = "enum", default)]
637 pub allowed: Vec<Value>,
638 #[serde(rename = "minLength", default)]
639 pub min_length: Option<i64>,
640 #[serde(rename = "maxLength", default)]
641 pub max_length: Option<i64>,
642 #[serde(default)]
643 pub minimum: Option<f64>,
644 #[serde(default)]
645 pub maximum: Option<f64>,
646 #[serde(rename = "multipleOf", default)]
647 pub multiple_of: Option<f64>,
648 #[serde(default)]
651 pub pattern: Option<String>,
652 #[serde(flatten)]
653 pub extra: BTreeMap<String, Value>,
654}
655
656impl PropertySchema {
657 pub fn validate_value(&self, value: &Value) -> Vec<String> {
660 let mut out = Vec::new();
661 let type_ok = match self.prop_type {
662 PropertyType::String => value.is_string(),
663 PropertyType::Integer => value.is_i64() || value.is_u64(),
664 PropertyType::Number => value.is_number(),
665 PropertyType::Boolean => value.is_boolean(),
666 PropertyType::Unknown => true,
667 };
668 if !type_ok {
669 out.push(format!(
670 "expected {:?}, got {}",
671 self.prop_type,
672 value_label(value)
673 ));
674 return out;
676 }
677 if !self.allowed.is_empty() && !self.allowed.iter().any(|candidate| candidate == value) {
678 let rendered: Vec<String> = self.allowed.iter().map(value_label).collect();
679 out.push(format!(
680 "{} is not in enum [{}]",
681 value_label(value),
682 rendered.join(", ")
683 ));
684 }
685 if let Some(text) = value.as_str() {
686 let len = text.chars().count() as i64;
687 if let Some(min) = self.min_length
688 && len < min
689 {
690 out.push(format!("length {len} < minLength {min}"));
691 }
692 if let Some(max) = self.max_length
693 && len > max
694 {
695 out.push(format!("length {len} > maxLength {max}"));
696 }
697 }
698 if let Some(number) = value.as_f64() {
699 if let Some(min) = self.minimum
700 && number < min
701 {
702 out.push(format!("{number} < minimum {min}"));
703 }
704 if let Some(max) = self.maximum
705 && number > max
706 {
707 out.push(format!("{number} > maximum {max}"));
708 }
709 if let Some(step) = self.multiple_of
710 && step != 0.0
711 && (number / step).fract().abs() > f64::EPSILON
712 {
713 out.push(format!("{number} is not a multiple of {step}"));
714 }
715 }
716 out
717 }
718
719 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
720 push_extra(out, at, &self.extra);
721 push_unknown(out, at, "type", self.prop_type == PropertyType::Unknown);
722 }
723}
724
725#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
726#[serde(rename_all = "lowercase")]
727pub enum PropertyType {
728 String,
729 Integer,
730 Number,
731 Boolean,
732 #[serde(other)]
733 Unknown,
734}
735
736fn push_extra(out: &mut Vec<String>, at: &str, extra: &BTreeMap<String, Value>) {
737 for key in extra.keys() {
738 out.push(format!("{at}: unmodeled field `{key}`"));
739 }
740}
741
742fn push_unknown(out: &mut Vec<String>, at: &str, field: &str, is_unknown: bool) {
743 if is_unknown {
744 out.push(format!("{at}.{field}: unknown enum value"));
745 }
746}
747
748fn value_label(value: &Value) -> String {
749 match value {
750 Value::String(s) => format!("\"{s}\""),
751 other => other.to_string(),
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use serde_json::json;
759
760 fn service(reference: &str, schema: Value, pricing: Value) -> ServiceDetail {
761 let (provider, service_id) = reference.split_once('/').unwrap();
762 serde_json::from_value(json!({
763 "id": "prvsvc_test",
764 "object": "v2.provisioning.provider_service_detail",
765 "provider_id": "prvdr_test",
766 "provider_name": provider,
767 "service_id": service_id,
768 "categories": [],
769 "kind": "deployable",
770 "scope": "project",
771 "availability": "available",
772 "development": false,
773 "livemode": true,
774 "pricing": pricing,
775 "configuration_schema": schema,
776 }))
777 .unwrap()
778 }
779
780 #[test]
781 fn reference_is_provider_lowercased_slash_service_id() {
782 let svc = service("Render/postgres", json!({}), json!({"type": "free"}));
783 assert_eq!(svc.reference(), "render/postgres");
784 }
785
786 #[test]
787 fn validate_accepts_pricing_selector_keys_outside_schema() {
788 let svc = service(
790 "Render/postgres",
791 json!({
792 "type": "object",
793 "required": ["name"],
794 "additionalProperties": false,
795 "properties": { "name": {"type": "string"}, "version": {"type": "string"} }
796 }),
797 json!({
798 "type": "paid",
799 "paid_pricing": [
800 {"configuration": {"instance_type": "free"}, "is_default": true, "type": "free"},
801 {"configuration": {"instance_type": "basic-256mb"}, "type": "freeform"}
802 ]
803 }),
804 );
805 svc.validate_config(
806 &json!({"name": "db", "version": "17", "instance_type": "basic-256mb"}),
807 )
808 .unwrap();
809 }
810
811 #[test]
812 fn validate_rejects_unknown_field_and_bad_tier_and_missing_required() {
813 let svc = service(
814 "Render/postgres",
815 json!({
816 "type": "object",
817 "required": ["name"],
818 "additionalProperties": false,
819 "properties": { "name": {"type": "string"} }
820 }),
821 json!({
822 "type": "paid",
823 "paid_pricing": [
824 {"configuration": {"instance_type": "free"}, "is_default": true, "type": "free"},
825 {"configuration": {"instance_type": "basic-256mb"}, "type": "freeform"}
826 ]
827 }),
828 );
829 let err = svc
830 .validate_config(&json!({"bogus": 1, "instance_type": "nope"}))
831 .unwrap_err();
832 assert!(
833 err.iter()
834 .any(|v| v.contains("missing required field `name`")),
835 "{err:?}"
836 );
837 assert!(
838 err.iter().any(|v| v.contains("unknown field `bogus`")),
839 "{err:?}"
840 );
841 assert!(
842 err.iter().any(|v| v.contains("not an allowed tier value")),
843 "{err:?}"
844 );
845 }
846
847 #[test]
848 fn validate_enforces_enum_and_type() {
849 let svc = service(
850 "Render/web-service",
851 json!({
852 "type": "object",
853 "required": ["name", "runtime"],
854 "additionalProperties": false,
855 "properties": {
856 "name": {"type": "string"},
857 "runtime": {"type": "string", "enum": ["rust", "node"]},
858 "auto_deploy": {"type": "string", "enum": ["yes", "no"]}
859 }
860 }),
861 json!({"type": "paid", "paid_pricing": [
862 {"configuration": {"instance_type": "free"}, "is_default": true, "type": "free"}
863 ]}),
864 );
865 svc.validate_config(&json!({"name": "api", "runtime": "rust", "auto_deploy": "no"}))
866 .unwrap();
867 let err = svc
868 .validate_config(&json!({"name": "api", "runtime": "elixir"}))
869 .unwrap_err();
870 assert!(err.iter().any(|v| v.contains("not in enum")), "{err:?}");
871 }
872
873 #[test]
874 fn requires_confirmation_is_per_tier() {
875 let svc = service(
876 "Render/postgres",
877 json!({"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}}),
878 json!({
879 "type": "paid",
880 "paid_pricing": [
881 {"configuration": {"instance_type": "free"}, "is_default": true, "type": "free"},
882 {"configuration": {"instance_type": "basic-256mb"}, "type": "freeform"}
883 ]
884 }),
885 );
886 assert!(svc.requires_confirmation(&json!({"name": "db", "instance_type": "basic-256mb"})));
887 assert!(!svc.requires_confirmation(&json!({"name": "db"})));
889 }
890
891 #[test]
892 fn requires_confirmation_falls_back_to_pricing_kind() {
893 let free = service("Render/static-site", json!({}), json!({"type": "free"}));
894 assert!(!free.requires_confirmation(&json!({})));
895 let paid = service(
896 "Vercel/pro",
897 json!({}),
898 json!({"type": "paid", "paid_pricing": [{"type": "freeform"}]}),
899 );
900 assert!(paid.requires_confirmation(&json!({})));
901 let component = service("Clerk/auth", json!({}), json!({"type": "component"}));
902 assert!(!component.requires_confirmation(&json!({})));
903 }
904
905 #[test]
906 fn required_parent_services_returns_single_parent_only() {
907 let kv = service(
908 "cloudflare/kv",
909 json!({"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]}),
910 json!({
911 "type": "component",
912 "component": {
913 "options": [
914 {"type": "free", "parent_services": ["workers:free"]},
915 {"type": "paid", "parent_services": ["workers:paid"]}
916 ]
917 }
918 }),
919 );
920 assert_eq!(
921 kv.required_parent_services(&json!({"title": "cache"}), false),
922 vec!["workers:free"]
923 );
924 assert_eq!(
925 kv.required_parent_services(&json!({"title": "cache"}), true),
926 vec!["workers:paid"]
927 );
928 assert!(kv.requires_confirmation_with_paid(&json!({"title": "cache"}), true));
929
930 let vercel = service(
931 "vercel/project",
932 json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}),
933 json!({
934 "type": "component",
935 "component": {
936 "options": [
937 {"type": "free", "parent_services": ["pro", "hobby"]}
938 ]
939 }
940 }),
941 );
942 assert!(
943 vercel
944 .required_parent_services(&json!({"name": "demo"}), false)
945 .is_empty()
946 );
947 }
948
949 #[test]
950 fn match_option_without_paid_consent_ignores_paid_default() {
951 let svc = service(
952 "cloudflare/kv",
953 json!({}),
954 json!({
955 "type": "component",
956 "component": {
957 "options": [
958 {"type": "paid", "is_default": true, "parent_services": ["workers:paid"]},
959 {"type": "free", "parent_services": ["workers:free"]}
960 ]
961 }
962 }),
963 );
964 assert!(!svc.requires_confirmation_with_paid(&json!({}), false));
965 assert_eq!(
966 svc.required_parent_services(&json!({}), false),
967 vec!["workers:free"]
968 );
969 assert!(svc.requires_confirmation_with_paid(&json!({}), true));
970 assert_eq!(
971 svc.required_parent_services(&json!({}), true),
972 vec!["workers:paid"]
973 );
974 }
975
976 #[test]
977 fn match_option_with_paid_consent_ignores_free_default() {
978 let svc = service(
979 "cloudflare/kv",
980 json!({}),
981 json!({
982 "type": "component",
983 "component": {
984 "options": [
985 {"type": "free", "is_default": true, "parent_services": ["workers:free"]},
986 {"type": "paid", "parent_services": ["workers:paid"]}
987 ]
988 }
989 }),
990 );
991 assert_eq!(
992 svc.required_parent_services(&json!({}), true),
993 vec!["workers:paid"]
994 );
995 }
996}