use faucet_core::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct UsageSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pricing_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pricing: Option<PricingOverrides>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PricingSpec {
pub currency: String,
pub egress_per_gb: f64,
pub object_storage: ObjectStoragePricing,
pub warehouse: WarehousePricing,
pub hosted_elt_per_million_rows: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ObjectStoragePricing {
pub read_per_1k_requests: f64,
pub write_per_1k_requests: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WarehousePricing {
pub bigquery_per_tib_scanned: f64,
pub bigquery_streaming_per_gib: f64,
pub snowflake_per_credit: f64,
}
impl Default for PricingSpec {
fn default() -> Self {
Self {
currency: "USD".to_string(),
egress_per_gb: 0.0,
object_storage: ObjectStoragePricing {
read_per_1k_requests: 0.0004,
write_per_1k_requests: 0.005,
},
warehouse: WarehousePricing {
bigquery_per_tib_scanned: 6.25,
bigquery_streaming_per_gib: 0.05,
snowflake_per_credit: 3.0,
},
hosted_elt_per_million_rows: 15.0,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PricingOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub egress_per_gb: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object_storage: Option<ObjectStorageOverrides>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub warehouse: Option<WarehouseOverrides>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hosted_elt_per_million_rows: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ObjectStorageOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub read_per_1k_requests: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub write_per_1k_requests: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WarehouseOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bigquery_per_tib_scanned: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bigquery_streaming_per_gib: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snowflake_per_credit: Option<f64>,
}
impl PricingOverrides {
pub fn apply(&self, mut base: PricingSpec) -> PricingSpec {
if let Some(c) = &self.currency {
base.currency = c.clone();
}
if let Some(v) = self.egress_per_gb {
base.egress_per_gb = v;
}
if let Some(o) = &self.object_storage {
if let Some(v) = o.read_per_1k_requests {
base.object_storage.read_per_1k_requests = v;
}
if let Some(v) = o.write_per_1k_requests {
base.object_storage.write_per_1k_requests = v;
}
}
if let Some(w) = &self.warehouse {
if let Some(v) = w.bigquery_per_tib_scanned {
base.warehouse.bigquery_per_tib_scanned = v;
}
if let Some(v) = w.bigquery_streaming_per_gib {
base.warehouse.bigquery_streaming_per_gib = v;
}
if let Some(v) = w.snowflake_per_credit {
base.warehouse.snowflake_per_credit = v;
}
}
if let Some(v) = self.hosted_elt_per_million_rows {
base.hosted_elt_per_million_rows = v;
}
base
}
}
impl PricingSpec {
pub fn validate(&self) -> Result<(), String> {
let rates = [
("egress_per_gb", self.egress_per_gb),
(
"object_storage.read_per_1k_requests",
self.object_storage.read_per_1k_requests,
),
(
"object_storage.write_per_1k_requests",
self.object_storage.write_per_1k_requests,
),
(
"warehouse.bigquery_per_tib_scanned",
self.warehouse.bigquery_per_tib_scanned,
),
(
"warehouse.bigquery_streaming_per_gib",
self.warehouse.bigquery_streaming_per_gib,
),
(
"warehouse.snowflake_per_credit",
self.warehouse.snowflake_per_credit,
),
(
"hosted_elt_per_million_rows",
self.hosted_elt_per_million_rows,
),
];
for (name, v) in rates {
if !v.is_finite() || v < 0.0 {
return Err(format!(
"pricing.{name} must be a non-negative number, got {v}"
));
}
}
if self.currency.trim().is_empty() {
return Err("pricing.currency must not be empty".to_string());
}
Ok(())
}
}
impl UsageSpec {
pub fn resolve_pricing(
&self,
base_dir: Option<&std::path::Path>,
) -> Result<PricingSpec, String> {
let mut pricing = PricingSpec::default();
if let Some(file) = &self.pricing_file {
let path = match base_dir {
Some(d) if std::path::Path::new(file).is_relative() => d.join(file),
_ => std::path::PathBuf::from(file),
};
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("usage.pricing_file `{}`: {e}", path.display()))?;
let overrides: PricingOverrides = if path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("json"))
{
serde_json::from_str(&text)
.map_err(|e| format!("usage.pricing_file `{}`: {e}", path.display()))?
} else {
serde_yaml::from_str(&text)
.map_err(|e| format!("usage.pricing_file `{}`: {e}", path.display()))?
};
pricing = overrides.apply(pricing);
}
if let Some(inline) = &self.pricing {
pricing = inline.apply(pricing);
}
pricing.validate()?;
Ok(pricing)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_valid_and_overrides_layer() {
let base = PricingSpec::default();
base.validate().unwrap();
assert_eq!(base.currency, "USD");
let spec: UsageSpec = serde_yaml::from_str(
"pricing:\n currency: EUR\n egress_per_gb: 0.09\n warehouse: { bigquery_per_tib_scanned: 5 }\n",
)
.unwrap();
let p = spec.resolve_pricing(None).unwrap();
assert_eq!(p.currency, "EUR");
assert_eq!(p.egress_per_gb, 0.09);
assert_eq!(p.warehouse.bigquery_per_tib_scanned, 5.0);
assert_eq!(p.warehouse.snowflake_per_credit, 3.0, "untouched default");
assert_eq!(p.object_storage.write_per_1k_requests, 0.005);
}
#[test]
fn pricing_file_layers_under_inline_and_bad_rates_are_refused() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pricing.yaml"),
"hosted_elt_per_million_rows: 40\nobject_storage: { read_per_1k_requests: 0.001 }\n",
)
.unwrap();
let spec: UsageSpec = serde_yaml::from_str(
"pricing_file: pricing.yaml\npricing: { hosted_elt_per_million_rows: 30 }\n",
)
.unwrap();
let p = spec.resolve_pricing(Some(dir.path())).unwrap();
assert_eq!(p.hosted_elt_per_million_rows, 30.0, "inline wins");
assert_eq!(p.object_storage.read_per_1k_requests, 0.001, "file applied");
std::fs::write(dir.path().join("bad.json"), r#"{"egress_per_gb": -1}"#).unwrap();
let spec: UsageSpec = serde_yaml::from_str("pricing_file: bad.json\n").unwrap();
let err = spec.resolve_pricing(Some(dir.path())).unwrap_err();
assert!(err.contains("egress_per_gb"), "{err}");
let spec: UsageSpec = serde_yaml::from_str("pricing_file: missing.yaml\n").unwrap();
assert!(spec.resolve_pricing(Some(dir.path())).is_err());
let spec: UsageSpec = serde_yaml::from_str("pricing: { currency: \" \" }\n").unwrap();
assert!(spec.resolve_pricing(None).unwrap_err().contains("currency"));
}
#[test]
fn every_override_applies_and_pricing_files_resolve() {
let o = PricingOverrides {
currency: Some("EUR".into()),
egress_per_gb: Some(0.1),
object_storage: Some(ObjectStorageOverrides {
read_per_1k_requests: Some(1.0),
write_per_1k_requests: Some(2.0),
}),
warehouse: Some(WarehouseOverrides {
bigquery_per_tib_scanned: Some(3.0),
bigquery_streaming_per_gib: Some(4.0),
snowflake_per_credit: Some(5.0),
}),
hosted_elt_per_million_rows: Some(6.0),
};
let p = o.apply(PricingSpec::default());
assert_eq!(p.object_storage.read_per_1k_requests, 1.0);
assert_eq!(p.object_storage.write_per_1k_requests, 2.0);
assert_eq!(p.warehouse.bigquery_per_tib_scanned, 3.0);
assert_eq!(p.warehouse.bigquery_streaming_per_gib, 4.0);
assert_eq!(p.warehouse.snowflake_per_credit, 5.0);
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("rates.yaml"), "currency: GBP\n").unwrap();
let rel = UsageSpec {
pricing_file: Some("rates.yaml".into()),
pricing: None,
};
assert_eq!(
rel.resolve_pricing(Some(dir.path())).unwrap().currency,
"GBP"
);
let json = dir.path().join("rates.json");
std::fs::write(&json, r#"{"currency": "JPY"}"#).unwrap();
let abs = UsageSpec {
pricing_file: Some(json.display().to_string()),
pricing: None,
};
assert_eq!(
abs.resolve_pricing(Some(dir.path())).unwrap().currency,
"JPY"
);
std::fs::write(&json, "{").unwrap();
assert!(abs.resolve_pricing(None).is_err());
}
}