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 properties: BTreeMap<String, PropertySchema>,
616 #[serde(flatten)]
617 pub extra: BTreeMap<String, Value>,
618}
619
620impl ConfigSchema {
621 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
622 push_extra(out, at, &self.extra);
623 for (name, property) in &self.properties {
624 property.collect_drift(out, &format!("{at}.properties.{name}"));
625 }
626 }
627}
628
629#[derive(Debug, Clone, Deserialize, Serialize)]
630pub struct PropertySchema {
631 #[serde(rename = "type")]
632 pub prop_type: PropertyType,
633 #[serde(default)]
634 pub description: Option<String>,
635 #[serde(default)]
636 pub title: Option<String>,
637 #[serde(default)]
638 pub default: Option<Value>,
639 #[serde(rename = "enum", default)]
640 pub allowed: Vec<Value>,
641 #[serde(rename = "minLength", default)]
642 pub min_length: Option<i64>,
643 #[serde(rename = "maxLength", default)]
644 pub max_length: Option<i64>,
645 #[serde(default)]
646 pub minimum: Option<f64>,
647 #[serde(default)]
648 pub maximum: Option<f64>,
649 #[serde(rename = "multipleOf", default)]
650 pub multiple_of: Option<f64>,
651 #[serde(default)]
654 pub pattern: Option<String>,
655 #[serde(flatten)]
656 pub extra: BTreeMap<String, Value>,
657}
658
659impl PropertySchema {
660 pub fn validate_value(&self, value: &Value) -> Vec<String> {
663 let mut out = Vec::new();
664 let type_ok = match self.prop_type {
665 PropertyType::String => value.is_string(),
666 PropertyType::Integer => value.is_i64() || value.is_u64(),
667 PropertyType::Number => value.is_number(),
668 PropertyType::Boolean => value.is_boolean(),
669 PropertyType::Unknown => true,
670 };
671 if !type_ok {
672 out.push(format!(
673 "expected {:?}, got {}",
674 self.prop_type,
675 value_label(value)
676 ));
677 return out;
679 }
680 if !self.allowed.is_empty() && !self.allowed.iter().any(|candidate| candidate == value) {
681 let rendered: Vec<String> = self.allowed.iter().map(value_label).collect();
682 out.push(format!(
683 "{} is not in enum [{}]",
684 value_label(value),
685 rendered.join(", ")
686 ));
687 }
688 if let Some(text) = value.as_str() {
689 let len = text.chars().count() as i64;
690 if let Some(min) = self.min_length
691 && len < min
692 {
693 out.push(format!("length {len} < minLength {min}"));
694 }
695 if let Some(max) = self.max_length
696 && len > max
697 {
698 out.push(format!("length {len} > maxLength {max}"));
699 }
700 }
701 if let Some(number) = value.as_f64() {
702 if let Some(min) = self.minimum
703 && number < min
704 {
705 out.push(format!("{number} < minimum {min}"));
706 }
707 if let Some(max) = self.maximum
708 && number > max
709 {
710 out.push(format!("{number} > maximum {max}"));
711 }
712 if let Some(step) = self.multiple_of
713 && step != 0.0
714 && (number / step).fract().abs() > f64::EPSILON
715 {
716 out.push(format!("{number} is not a multiple of {step}"));
717 }
718 }
719 out
720 }
721
722 fn collect_drift(&self, out: &mut Vec<String>, at: &str) {
723 push_extra(out, at, &self.extra);
724 push_unknown(out, at, "type", self.prop_type == PropertyType::Unknown);
725 }
726}
727
728#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
729#[serde(rename_all = "lowercase")]
730pub enum PropertyType {
731 String,
732 Integer,
733 Number,
734 Boolean,
735 #[serde(other)]
736 Unknown,
737}
738
739fn push_extra(out: &mut Vec<String>, at: &str, extra: &BTreeMap<String, Value>) {
740 for key in extra.keys() {
741 out.push(format!("{at}: unmodeled field `{key}`"));
742 }
743}
744
745fn push_unknown(out: &mut Vec<String>, at: &str, field: &str, is_unknown: bool) {
746 if is_unknown {
747 out.push(format!("{at}.{field}: unknown enum value"));
748 }
749}
750
751fn value_label(value: &Value) -> String {
752 match value {
753 Value::String(s) => format!("\"{s}\""),
754 other => other.to_string(),
755 }
756}
757
758#[cfg(test)]
759mod tests {
760 use super::*;
761 use serde_json::json;
762
763 fn service(reference: &str, schema: Value, pricing: Value) -> ServiceDetail {
764 let (provider, service_id) = reference.split_once('/').unwrap();
765 serde_json::from_value(json!({
766 "id": "prvsvc_test",
767 "object": "v2.provisioning.provider_service_detail",
768 "provider_id": "prvdr_test",
769 "provider_name": provider,
770 "service_id": service_id,
771 "categories": [],
772 "kind": "deployable",
773 "scope": "project",
774 "availability": "available",
775 "development": false,
776 "livemode": true,
777 "pricing": pricing,
778 "configuration_schema": schema,
779 }))
780 .unwrap()
781 }
782
783 #[test]
784 fn reference_is_provider_lowercased_slash_service_id() {
785 let svc = service("Render/postgres", json!({}), json!({"type": "free"}));
786 assert_eq!(svc.reference(), "render/postgres");
787 }
788
789 #[test]
790 fn validate_accepts_pricing_selector_keys_outside_schema() {
791 let svc = service(
793 "Render/postgres",
794 json!({
795 "type": "object",
796 "required": ["name"],
797 "additionalProperties": false,
798 "properties": { "name": {"type": "string"}, "version": {"type": "string"} }
799 }),
800 json!({
801 "type": "paid",
802 "paid_pricing": [
803 {"configuration": {"instance_type": "free"}, "is_default": true, "type": "free"},
804 {"configuration": {"instance_type": "basic-256mb"}, "type": "freeform"}
805 ]
806 }),
807 );
808 svc.validate_config(
809 &json!({"name": "db", "version": "17", "instance_type": "basic-256mb"}),
810 )
811 .unwrap();
812 }
813
814 #[test]
815 fn validate_rejects_unknown_field_and_bad_tier_and_missing_required() {
816 let svc = service(
817 "Render/postgres",
818 json!({
819 "type": "object",
820 "required": ["name"],
821 "additionalProperties": false,
822 "properties": { "name": {"type": "string"} }
823 }),
824 json!({
825 "type": "paid",
826 "paid_pricing": [
827 {"configuration": {"instance_type": "free"}, "is_default": true, "type": "free"},
828 {"configuration": {"instance_type": "basic-256mb"}, "type": "freeform"}
829 ]
830 }),
831 );
832 let err = svc
833 .validate_config(&json!({"bogus": 1, "instance_type": "nope"}))
834 .unwrap_err();
835 assert!(
836 err.iter()
837 .any(|v| v.contains("missing required field `name`")),
838 "{err:?}"
839 );
840 assert!(
841 err.iter().any(|v| v.contains("unknown field `bogus`")),
842 "{err:?}"
843 );
844 assert!(
845 err.iter().any(|v| v.contains("not an allowed tier value")),
846 "{err:?}"
847 );
848 }
849
850 #[test]
851 fn validate_enforces_enum_and_type() {
852 let svc = service(
853 "Render/web-service",
854 json!({
855 "type": "object",
856 "required": ["name", "runtime"],
857 "additionalProperties": false,
858 "properties": {
859 "name": {"type": "string"},
860 "runtime": {"type": "string", "enum": ["rust", "node"]},
861 "auto_deploy": {"type": "string", "enum": ["yes", "no"]}
862 }
863 }),
864 json!({"type": "paid", "paid_pricing": [
865 {"configuration": {"instance_type": "free"}, "is_default": true, "type": "free"}
866 ]}),
867 );
868 svc.validate_config(&json!({"name": "api", "runtime": "rust", "auto_deploy": "no"}))
869 .unwrap();
870 let err = svc
871 .validate_config(&json!({"name": "api", "runtime": "elixir"}))
872 .unwrap_err();
873 assert!(err.iter().any(|v| v.contains("not in enum")), "{err:?}");
874 }
875
876 #[test]
877 fn requires_confirmation_is_per_tier() {
878 let svc = service(
879 "Render/postgres",
880 json!({"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}}),
881 json!({
882 "type": "paid",
883 "paid_pricing": [
884 {"configuration": {"instance_type": "free"}, "is_default": true, "type": "free"},
885 {"configuration": {"instance_type": "basic-256mb"}, "type": "freeform"}
886 ]
887 }),
888 );
889 assert!(svc.requires_confirmation(&json!({"name": "db", "instance_type": "basic-256mb"})));
890 assert!(!svc.requires_confirmation(&json!({"name": "db"})));
892 }
893
894 #[test]
895 fn requires_confirmation_falls_back_to_pricing_kind() {
896 let free = service("Render/static-site", json!({}), json!({"type": "free"}));
897 assert!(!free.requires_confirmation(&json!({})));
898 let paid = service(
899 "Vercel/pro",
900 json!({}),
901 json!({"type": "paid", "paid_pricing": [{"type": "freeform"}]}),
902 );
903 assert!(paid.requires_confirmation(&json!({})));
904 let component = service("Clerk/auth", json!({}), json!({"type": "component"}));
905 assert!(!component.requires_confirmation(&json!({})));
906 }
907
908 #[test]
909 fn required_parent_services_returns_single_parent_only() {
910 let kv = service(
911 "cloudflare/kv",
912 json!({"type": "object", "properties": {"title": {"type": "string"}}, "required": ["title"]}),
913 json!({
914 "type": "component",
915 "component": {
916 "options": [
917 {"type": "free", "parent_services": ["workers:free"]},
918 {"type": "paid", "parent_services": ["workers:paid"]}
919 ]
920 }
921 }),
922 );
923 assert_eq!(
924 kv.required_parent_services(&json!({"title": "cache"}), false),
925 vec!["workers:free"]
926 );
927 assert_eq!(
928 kv.required_parent_services(&json!({"title": "cache"}), true),
929 vec!["workers:paid"]
930 );
931 assert!(kv.requires_confirmation_with_paid(&json!({"title": "cache"}), true));
932
933 let vercel = service(
934 "vercel/project",
935 json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}),
936 json!({
937 "type": "component",
938 "component": {
939 "options": [
940 {"type": "free", "parent_services": ["pro", "hobby"]}
941 ]
942 }
943 }),
944 );
945 assert!(
946 vercel
947 .required_parent_services(&json!({"name": "demo"}), false)
948 .is_empty()
949 );
950 }
951
952 #[test]
953 fn match_option_without_paid_consent_ignores_paid_default() {
954 let svc = service(
955 "cloudflare/kv",
956 json!({}),
957 json!({
958 "type": "component",
959 "component": {
960 "options": [
961 {"type": "paid", "is_default": true, "parent_services": ["workers:paid"]},
962 {"type": "free", "parent_services": ["workers:free"]}
963 ]
964 }
965 }),
966 );
967 assert!(!svc.requires_confirmation_with_paid(&json!({}), false));
968 assert_eq!(
969 svc.required_parent_services(&json!({}), false),
970 vec!["workers:free"]
971 );
972 assert!(svc.requires_confirmation_with_paid(&json!({}), true));
973 assert_eq!(
974 svc.required_parent_services(&json!({}), true),
975 vec!["workers:paid"]
976 );
977 }
978
979 #[test]
980 fn match_option_with_paid_consent_ignores_free_default() {
981 let svc = service(
982 "cloudflare/kv",
983 json!({}),
984 json!({
985 "type": "component",
986 "component": {
987 "options": [
988 {"type": "free", "is_default": true, "parent_services": ["workers:free"]},
989 {"type": "paid", "parent_services": ["workers:paid"]}
990 ]
991 }
992 }),
993 );
994 assert_eq!(
995 svc.required_parent_services(&json!({}), true),
996 vec!["workers:paid"]
997 );
998 }
999}