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    /// Present on provider-filtered responses; wire type varies (string or object).
27    #[serde(default)]
28    pub provider: Option<Value>,
29    /// Present on category-filtered responses; wire type varies (string or object).
30    #[serde(default)]
31    pub category_filter: Option<Value>,
32    /// Echo of the provider filter token; wire type varies (string or object).
33    #[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    /// Parse a full `{ok, command, version, data}` envelope and return its
44    /// `data` as a [`Catalog`].
45    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    /// Look up a service by its `stripe projects add` reference
54    /// (`provider_name` lowercased + `/` + `service_id`).
55    pub fn lookup(&self, reference: &str) -> Option<&ServiceDetail> {
56        self.services.iter().find(|s| s.reference() == reference)
57    }
58
59    /// Collect every unmodeled field (`extra`) and every `Unknown` enum across
60    /// the whole catalog. Empty means the model fully covers the wire format.
61    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    /// The `stripe projects add <reference>` key.
110    pub fn reference(&self) -> String {
111        format!(
112            "{}/{}",
113            self.provider_name.to_ascii_lowercase(),
114            self.service_id
115        )
116    }
117
118    /// Validate a serialized `--config` object against this service: required
119    /// fields present, every key is a known schema property or pricing-tier
120    /// selector, each value matches its property/selector constraints.
121    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        // Required schema fields must be present.
135        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    /// Whether provisioning this service with the given `--config` requires paid
180    /// confirmation, derived from the selected pricing tier (or the service's
181    /// pricing kind when there are no tier selectors).
182    pub fn requires_confirmation(&self, config: &Value) -> bool {
183        self.requires_confirmation_with_paid(config, false)
184    }
185
186    /// Like [`requires_confirmation`], but honors an explicit paid-consent flag
187    /// for component-pricing tier selection.
188    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    /// Parent plan `service_id`s that must be provisioned before this service.
194    /// Only returns parents when the catalog names exactly one (multi-parent
195    /// tiers like `vercel/project` are handled by the substrate).
196    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    /// Keys that select a pricing tier via `--config` (the union of keys across
362    /// `paid_pricing[].configuration`, e.g. `instance_type`).
363    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    /// Distinct allowed values for a tier-selector key.
374    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    /// The pricing tier selected by a `--config`: the entry whose
388    /// `configuration` is fully satisfied by `config`, else the default entry.
389    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    /// Whether the selected tier requires paid confirmation.
409    pub fn requires_confirmation(&self, config: &Value) -> bool {
410        self.requires_confirmation_with_paid(config, false)
411    }
412
413    /// Like [`requires_confirmation`], but selects component-pricing tiers with
414    /// `confirm_paid` so parent-plan provisioning matches the child add.
415    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    /// Parent plan `service_id`s required before provisioning `config`, from the
433    /// selected component-pricing option. Empty when unambiguous parents are
434    /// absent (multi-parent options are left to substrate-specific handling).
435    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    /// Select a component-pricing option by paid-consent flag (default + free/paid
543    /// kind). Unlike [`Pricing::match_tier`], options are not matched against `config`.
544    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/// A JSON-Schema subset describing a service's `--config`. Tri-state on the
603/// wire: absent, `{}` (all defaults here), or a full object.
604#[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    // `pattern` is modeled so it does not trip drift detection; not enforced
652    // (no regex dependency, and none of the references we provision use it).
653    #[serde(default)]
654    pub pattern: Option<String>,
655    #[serde(flatten)]
656    pub extra: BTreeMap<String, Value>,
657}
658
659impl PropertySchema {
660    /// Validate one config value against this property; returns violation
661    /// details (empty when valid).
662    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            // Type is wrong; further constraint checks would be noise.
678            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        // render/postgres: instance_type is a tier selector, not a schema prop.
792        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        // No selector → default (free) tier → no confirmation.
891        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}