Skip to main content

stackless_stripe_projects/
catalog.rs

1//! Typed model of `stripe projects catalog --json`.
2//!
3//! The catalog is the single source of truth for every provisionable provider
4//! service: its `configuration_schema`, pricing tiers, constraints, and
5//! categories. Plugins declare a [`CatalogService`](verify::CatalogService)
6//! reference plus a typed config; [`ServiceDetail::validate_config`] checks that
7//! config against the catalog, and [`ServiceDetail::requires_confirmation`]
8//! derives paid confirmation from the selected pricing tier.
9//!
10//! Deserialization is permissive so `up` never breaks on additive drift: every
11//! struct captures unmodeled keys in `extra`, and every enum has an `Unknown`
12//! fallback. The strict [`Catalog::drift_report`] (used in tests) fails when the
13//! live catalog contains anything we have not modeled.
14
15pub mod verify;
16
17use std::collections::{BTreeMap, BTreeSet};
18
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22/// The `data` payload of a `stripe projects catalog --json` envelope.
23#[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    /// Parse a full `{ok, command, version, data}` envelope and return its
41    /// `data` as a [`Catalog`].
42    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    /// Look up a service by its `stripe projects add` reference
51    /// (`provider_name` lowercased + `/` + `service_id`).
52    pub fn lookup(&self, reference: &str) -> Option<&ServiceDetail> {
53        self.services.iter().find(|s| s.reference() == reference)
54    }
55
56    /// Collect every unmodeled field (`extra`) and every `Unknown` enum across
57    /// the whole catalog. Empty means the model fully covers the wire format.
58    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    /// The `stripe projects add <reference>` key.
107    pub fn reference(&self) -> String {
108        format!(
109            "{}/{}",
110            self.provider_name.to_ascii_lowercase(),
111            self.service_id
112        )
113    }
114
115    /// Validate a serialized `--config` object against this service: required
116    /// fields present, every key is a known schema property or pricing-tier
117    /// selector, each value matches its property/selector constraints.
118    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        // Required schema fields must be present.
132        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    /// Whether provisioning this service with the given `--config` requires paid
177    /// confirmation, derived from the selected pricing tier (or the service's
178    /// pricing kind when there are no tier selectors).
179    pub fn requires_confirmation(&self, config: &Value) -> bool {
180        self.requires_confirmation_with_paid(config, false)
181    }
182
183    /// Like [`requires_confirmation`], but honors an explicit paid-consent flag
184    /// for component-pricing tier selection.
185    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    /// Parent plan `service_id`s that must be provisioned before this service.
191    /// Only returns parents when the catalog names exactly one (multi-parent
192    /// tiers like `vercel/project` are handled by the substrate).
193    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    /// Keys that select a pricing tier via `--config` (the union of keys across
359    /// `paid_pricing[].configuration`, e.g. `instance_type`).
360    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    /// Distinct allowed values for a tier-selector key.
371    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    /// The pricing tier selected by a `--config`: the entry whose
385    /// `configuration` is fully satisfied by `config`, else the default entry.
386    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    /// Whether the selected tier requires paid confirmation.
406    pub fn requires_confirmation(&self, config: &Value) -> bool {
407        self.requires_confirmation_with_paid(config, false)
408    }
409
410    /// Like [`requires_confirmation`], but selects component-pricing tiers with
411    /// `confirm_paid` so parent-plan provisioning matches the child add.
412    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    /// Parent plan `service_id`s required before provisioning `config`, from the
430    /// selected component-pricing option. Empty when unambiguous parents are
431    /// absent (multi-parent options are left to substrate-specific handling).
432    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    /// Select a component-pricing option by paid-consent flag (default + free/paid
540    /// kind). Unlike [`Pricing::match_tier`], options are not matched against `config`.
541    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/// A JSON-Schema subset describing a service's `--config`. Tri-state on the
600/// wire: absent, `{}` (all defaults here), or a full object.
601#[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    // `pattern` is modeled so it does not trip drift detection; not enforced
649    // (no regex dependency, and none of the references we provision use it).
650    #[serde(default)]
651    pub pattern: Option<String>,
652    #[serde(flatten)]
653    pub extra: BTreeMap<String, Value>,
654}
655
656impl PropertySchema {
657    /// Validate one config value against this property; returns violation
658    /// details (empty when valid).
659    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            // Type is wrong; further constraint checks would be noise.
675            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        // render/postgres: instance_type is a tier selector, not a schema prop.
789        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        // No selector → default (free) tier → no confirmation.
888        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}