1use std::cell::Cell;
27
28use chrono::{DateTime, Datelike, Duration, LocalResult, TimeZone, Timelike, Utc};
29use rust_decimal::Decimal;
30use serde::{Deserialize, Serialize, Serializer};
31use serde_json::value::RawValue;
32use thiserror::Error;
33
34pub const FOCUS_VERSION: &str = "1.3";
35pub const DEFAULT_BILLING_CURRENCY: &str = "USD";
36pub const CHARGE_CATEGORY_USAGE: &str = "Usage";
37pub const CHARGE_FREQUENCY_USAGE_BASED: &str = "Usage-Based";
38pub const PRICING_CATEGORY_STANDARD: &str = "Standard";
39pub const PRICING_UNIT_TOKENS: &str = "tokens";
42pub const SERVICE_CATEGORY_AI: &str = "AI and Machine Learning";
43pub const SERVICE_SUBCATEGORY_GENERATIVE_AI: &str = "Generative AI";
47pub const PRICING_STATUS_MISSING_PRICE: &str = "missing_price";
48
49pub const BILLING_ACCOUNT_ID_LOCAL: &str = "costroid-local-estimate";
54pub const BILLING_ACCOUNT_NAME_LOCAL: &str = "Costroid local estimate";
56pub const BILLING_ACCOUNT_TYPE_LOCAL: &str = "Local estimate";
60
61pub type FocusTimestamp = DateTime<Utc>;
62
63#[derive(Debug, Error)]
64pub enum FocusError {
65 #[error("invalid timestamp for FOCUS period calculation")]
66 InvalidTimestamp,
67
68 #[error("failed to serialize FOCUS JSON: {0}")]
69 Json(#[from] serde_json::Error),
70
71 #[error("failed to serialize FOCUS CSV: {0}")]
72 Csv(#[from] csv::Error),
73
74 #[error("failed to flush FOCUS CSV: {0}")]
75 Io(#[from] std::io::Error),
76
77 #[error("failed to convert FOCUS CSV to UTF-8: {0}")]
78 Utf8(#[from] std::string::FromUtf8Error),
79}
80
81#[derive(Clone, Copy, PartialEq, Eq)]
91enum SerMode {
92 Json,
93 Csv,
94}
95
96thread_local! {
97 static SER_MODE: Cell<SerMode> = const { Cell::new(SerMode::Json) };
98}
99
100struct SerModeGuard(SerMode);
101
102impl SerModeGuard {
103 fn new(mode: SerMode) -> Self {
104 SerModeGuard(SER_MODE.with(|m| m.replace(mode)))
105 }
106}
107
108impl Drop for SerModeGuard {
109 fn drop(&mut self) {
110 SER_MODE.with(|m| m.set(self.0));
111 }
112}
113
114fn decimal_with_point(value: &Decimal) -> String {
118 let rendered = value.to_string();
119 if rendered.contains('.') {
120 rendered
121 } else {
122 format!("{rendered}.0")
123 }
124}
125
126fn serialize_decimal<S: Serializer>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error> {
127 match SER_MODE.with(Cell::get) {
128 SerMode::Csv => serializer.serialize_str(&decimal_with_point(value)),
129 SerMode::Json => RawValue::from_string(decimal_with_point(value))
130 .map_err(serde::ser::Error::custom)?
131 .serialize(serializer),
132 }
133}
134
135fn serialize_decimal_opt<S: Serializer>(
136 value: &Option<Decimal>,
137 serializer: S,
138) -> Result<S::Ok, S::Error> {
139 match value {
140 Some(value) => serialize_decimal(value, serializer),
141 None => serializer.serialize_none(),
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct FocusExportEnvelope<T> {
147 #[serde(rename = "focusVersion")]
148 pub focus_version: String,
149 pub rows: Vec<T>,
150}
151
152impl<T> FocusExportEnvelope<T> {
153 pub fn new(rows: Vec<T>) -> Self {
154 Self {
155 focus_version: FOCUS_VERSION.to_string(),
156 rows,
157 }
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum FocusAccessPath {
164 Api,
165 Subscription,
166 Unknown,
167}
168
169impl FocusAccessPath {
170 pub fn as_str(self) -> &'static str {
171 match self {
172 Self::Api => "api",
173 Self::Subscription => "subscription",
174 Self::Unknown => "unknown",
175 }
176 }
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum TokenType {
182 Input,
183 Output,
184 CacheRead,
185 CacheWrite,
186}
187
188impl TokenType {
189 pub fn as_str(self) -> &'static str {
190 match self {
191 Self::Input => "input",
192 Self::Output => "output",
193 Self::CacheRead => "cache_read",
194 Self::CacheWrite => "cache_write",
195 }
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct UnpricedUsage {
201 pub timestamp: DateTime<Utc>,
202 pub tool: String,
203 pub model: String,
204 pub token_type: TokenType,
205 pub token_count: u64,
206 pub project: Option<String>,
207 pub access_path: FocusAccessPath,
208 pub service_name: String,
209 pub service_provider_name: String,
210 pub host_provider_name: String,
211 pub invoice_issuer_name: String,
212 pub billing_currency: String,
213}
214
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[serde(rename_all = "PascalCase")]
222pub struct FocusRecord {
223 #[serde(serialize_with = "serialize_decimal")]
225 pub billed_cost: Decimal,
226 #[serde(serialize_with = "serialize_decimal")]
227 pub effective_cost: Decimal,
228 #[serde(serialize_with = "serialize_decimal")]
229 pub list_cost: Decimal,
230 #[serde(serialize_with = "serialize_decimal")]
231 pub contracted_cost: Decimal,
232
233 pub billing_account_id: String,
235 pub billing_account_name: String,
236 pub billing_account_type: Option<String>,
237 pub billing_currency: String,
238
239 pub billing_period_start: DateTime<Utc>,
241 pub billing_period_end: DateTime<Utc>,
242 pub charge_period_start: DateTime<Utc>,
243 pub charge_period_end: DateTime<Utc>,
244
245 pub charge_category: String,
247 pub charge_class: Option<String>,
248 pub charge_description: String,
249 pub charge_frequency: String,
250
251 pub service_name: String,
255 pub service_category: String,
256 pub service_subcategory: Option<String>,
257 pub service_provider_name: String,
258 pub host_provider_name: String,
259 pub invoice_issuer_name: String,
260 pub provider_name: String,
261 pub publisher_name: String,
262 pub invoice_id: Option<String>,
263
264 pub sku_id: Option<String>,
266 pub sku_price_id: Option<String>,
267 pub sku_meter: Option<String>,
268 pub sku_price_details: Option<String>,
269 pub pricing_category: Option<String>,
272 pub pricing_currency: String,
273 #[serde(serialize_with = "serialize_decimal_opt")]
274 pub pricing_quantity: Option<Decimal>,
275 pub pricing_unit: Option<String>,
276 #[serde(serialize_with = "serialize_decimal_opt")]
277 pub list_unit_price: Option<Decimal>,
278 #[serde(serialize_with = "serialize_decimal_opt")]
279 pub contracted_unit_price: Option<Decimal>,
280 #[serde(serialize_with = "serialize_decimal_opt")]
281 pub pricing_currency_list_unit_price: Option<Decimal>,
282 #[serde(serialize_with = "serialize_decimal_opt")]
283 pub pricing_currency_contracted_unit_price: Option<Decimal>,
284 #[serde(serialize_with = "serialize_decimal")]
285 pub pricing_currency_effective_cost: Decimal,
286
287 #[serde(serialize_with = "serialize_decimal_opt")]
290 pub consumed_quantity: Option<Decimal>,
291 pub consumed_unit: String,
292
293 pub commitment_discount_category: Option<String>,
295 pub commitment_discount_id: Option<String>,
296 pub commitment_discount_name: Option<String>,
297 #[serde(serialize_with = "serialize_decimal_opt")]
298 pub commitment_discount_quantity: Option<Decimal>,
299 pub commitment_discount_status: Option<String>,
300 pub commitment_discount_type: Option<String>,
301 pub commitment_discount_unit: Option<String>,
302 pub capacity_reservation_id: Option<String>,
303 pub capacity_reservation_status: Option<String>,
304 pub region_id: Option<String>,
305 pub region_name: Option<String>,
306 pub availability_zone: Option<String>,
307 pub resource_id: Option<String>,
308 pub resource_name: Option<String>,
309 pub resource_type: Option<String>,
310 pub sub_account_id: Option<String>,
311 pub sub_account_name: Option<String>,
312 pub sub_account_type: Option<String>,
313 pub tags: Option<String>,
314 pub contract_applied: Option<String>,
315 pub allocated_method_id: Option<String>,
316 pub allocated_method_details: Option<String>,
317 pub allocated_resource_id: Option<String>,
318 pub allocated_resource_name: Option<String>,
319 pub allocated_tags: Option<String>,
320
321 #[serde(rename = "x_Model")]
323 pub x_model: String,
324 #[serde(rename = "x_TokenType")]
325 pub x_token_type: String,
326 #[serde(rename = "x_AccessPath")]
327 pub x_access_path: String,
328 #[serde(rename = "x_Estimated")]
329 pub x_estimated: bool,
330 #[serde(rename = "x_Tool")]
331 pub x_tool: String,
332 #[serde(rename = "x_Project")]
333 pub x_project: Option<String>,
334 #[serde(rename = "x_PricingStatus")]
335 pub x_pricing_status: String,
336 #[serde(rename = "x_ConsumedTokens", serialize_with = "serialize_decimal")]
340 pub x_consumed_tokens: Decimal,
341}
342
343impl FocusRecord {
344 pub fn unpriced_usage(input: UnpricedUsage) -> Result<Self, FocusError> {
345 let charge_period_start = input
349 .timestamp
350 .with_nanosecond(0)
351 .unwrap_or(input.timestamp);
352 let (billing_period_start, billing_period_end) = billing_period(charge_period_start)?;
353 let charge_period_end = charge_period_start
354 .checked_add_signed(Duration::seconds(1))
355 .ok_or(FocusError::InvalidTimestamp)?;
356 let token_type = input.token_type.as_str();
357 let cost = Decimal::from(0);
358 let consumed_tokens = Decimal::from(input.token_count);
359
360 Ok(Self {
361 billed_cost: cost,
362 effective_cost: cost,
363 list_cost: cost,
364 contracted_cost: cost,
365 billing_account_id: BILLING_ACCOUNT_ID_LOCAL.to_string(),
366 billing_account_name: BILLING_ACCOUNT_NAME_LOCAL.to_string(),
367 billing_account_type: Some(BILLING_ACCOUNT_TYPE_LOCAL.to_string()),
368 billing_currency: input.billing_currency.clone(),
369 billing_period_start,
370 billing_period_end,
371 charge_period_start,
372 charge_period_end,
373 charge_category: CHARGE_CATEGORY_USAGE.to_string(),
374 charge_class: None,
375 charge_description: format!("{} {} tokens", input.model, token_type),
376 charge_frequency: CHARGE_FREQUENCY_USAGE_BASED.to_string(),
377 service_name: input.service_name,
378 service_category: SERVICE_CATEGORY_AI.to_string(),
379 service_subcategory: Some(SERVICE_SUBCATEGORY_GENERATIVE_AI.to_string()),
380 service_provider_name: input.service_provider_name.clone(),
381 host_provider_name: input.host_provider_name,
382 invoice_issuer_name: input.invoice_issuer_name.clone(),
383 provider_name: input.service_provider_name,
384 publisher_name: input.invoice_issuer_name,
385 invoice_id: None,
386 sku_id: Some(format!("{}:{token_type}", input.model)),
387 sku_price_id: None,
388 sku_meter: Some(token_type.to_string()),
389 sku_price_details: None,
390 pricing_category: None,
399 pricing_currency: input.billing_currency,
400 pricing_quantity: None,
401 pricing_unit: None,
402 list_unit_price: None,
403 contracted_unit_price: None,
404 pricing_currency_list_unit_price: None,
405 pricing_currency_contracted_unit_price: None,
406 pricing_currency_effective_cost: cost,
407 consumed_quantity: None,
408 consumed_unit: PRICING_UNIT_TOKENS.to_string(),
409 commitment_discount_category: None,
410 commitment_discount_id: None,
411 commitment_discount_name: None,
412 commitment_discount_quantity: None,
413 commitment_discount_status: None,
414 commitment_discount_type: None,
415 commitment_discount_unit: None,
416 capacity_reservation_id: None,
417 capacity_reservation_status: None,
418 region_id: None,
419 region_name: None,
420 availability_zone: None,
421 resource_id: None,
422 resource_name: None,
423 resource_type: None,
424 sub_account_id: None,
425 sub_account_name: None,
426 sub_account_type: None,
427 tags: None,
428 contract_applied: None,
429 allocated_method_id: None,
430 allocated_method_details: None,
431 allocated_resource_id: None,
432 allocated_resource_name: None,
433 allocated_tags: None,
434 x_model: input.model,
435 x_token_type: token_type.to_string(),
436 x_access_path: input.access_path.as_str().to_string(),
437 x_estimated: true,
438 x_tool: input.tool,
439 x_project: input.project,
440 x_pricing_status: PRICING_STATUS_MISSING_PRICE.to_string(),
441 x_consumed_tokens: consumed_tokens,
442 })
443 }
444}
445
446pub fn to_json_string(rows: Vec<FocusRecord>) -> Result<String, FocusError> {
447 let _guard = SerModeGuard::new(SerMode::Json);
448 let envelope = FocusExportEnvelope::new(rows);
449 serde_json::to_string_pretty(&envelope).map_err(FocusError::from)
450}
451
452pub fn to_csv_string(rows: &[FocusRecord]) -> Result<String, FocusError> {
453 let _guard = SerModeGuard::new(SerMode::Csv);
454 let mut writer = csv::Writer::from_writer(Vec::new());
455 for row in rows {
456 writer.serialize(row)?;
457 }
458 writer.flush()?;
459 let bytes = writer.get_ref().clone();
460 String::from_utf8(bytes).map_err(FocusError::from)
461}
462
463fn billing_period(timestamp: DateTime<Utc>) -> Result<(DateTime<Utc>, DateTime<Utc>), FocusError> {
464 let start = utc_datetime(timestamp.year(), timestamp.month(), 1)?;
465 let (next_year, next_month) = if timestamp.month() == 12 {
466 (timestamp.year() + 1, 1)
467 } else {
468 (timestamp.year(), timestamp.month() + 1)
469 };
470 let end = utc_datetime(next_year, next_month, 1)?;
471 Ok((start, end))
472}
473
474fn utc_datetime(year: i32, month: u32, day: u32) -> Result<DateTime<Utc>, FocusError> {
475 match Utc.with_ymd_and_hms(year, month, day, 0, 0, 0) {
476 LocalResult::Single(value) => Ok(value),
477 LocalResult::Ambiguous(_, _) | LocalResult::None => Err(FocusError::InvalidTimestamp),
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use chrono::LocalResult;
485
486 fn timestamp() -> DateTime<Utc> {
487 match Utc.with_ymd_and_hms(2026, 1, 15, 12, 34, 56) {
488 LocalResult::Single(value) => value,
489 LocalResult::Ambiguous(_, _) | LocalResult::None => {
490 panic!("test timestamp should be valid")
491 }
492 }
493 }
494
495 fn record() -> FocusRecord {
496 let input = UnpricedUsage {
497 timestamp: timestamp(),
498 tool: "codex".to_string(),
499 model: "example-model".to_string(),
500 token_type: TokenType::Input,
501 token_count: 1_500,
502 project: Some("/work/project".to_string()),
503 access_path: FocusAccessPath::Subscription,
504 service_name: "Codex".to_string(),
505 service_provider_name: "OpenAI".to_string(),
506 host_provider_name: "OpenAI".to_string(),
507 invoice_issuer_name: "OpenAI".to_string(),
508 billing_currency: DEFAULT_BILLING_CURRENCY.to_string(),
509 };
510 match FocusRecord::unpriced_usage(input) {
511 Ok(value) => value,
512 Err(err) => panic!("record should build: {err}"),
513 }
514 }
515
516 #[test]
517 fn export_envelope_uses_canonical_focus_version() {
518 let envelope = FocusExportEnvelope::<()>::new(Vec::new());
519
520 assert_eq!(envelope.focus_version, FOCUS_VERSION);
521 assert!(envelope.rows.is_empty());
522 }
523
524 #[test]
525 fn unpriced_usage_has_required_cost_and_pricing_markers() {
526 let record = record();
527
528 assert_eq!(record.billed_cost, Decimal::from(0));
529 assert_eq!(record.effective_cost, Decimal::from(0));
530 assert_eq!(record.list_cost, Decimal::from(0));
531 assert_eq!(record.contracted_cost, Decimal::from(0));
532 assert_eq!(record.sku_price_id, None);
534 assert_eq!(record.pricing_category, None);
535 assert_eq!(record.pricing_quantity, None);
536 assert_eq!(record.pricing_unit, None);
537 assert_eq!(record.consumed_quantity, None);
538 assert_eq!(record.list_unit_price, None);
539 assert_eq!(record.contracted_unit_price, None);
540 assert_eq!(record.pricing_currency_list_unit_price, None);
541 assert_eq!(record.x_consumed_tokens, Decimal::from(1_500));
543 assert_eq!(record.x_pricing_status, PRICING_STATUS_MISSING_PRICE);
544 }
545
546 #[test]
547 fn unpriced_usage_populates_mandatory_focus_columns() {
548 let record = record();
549
550 assert_eq!(record.billing_account_id, BILLING_ACCOUNT_ID_LOCAL);
552 assert_eq!(record.billing_account_name, BILLING_ACCOUNT_NAME_LOCAL);
553 assert_eq!(
554 record.billing_account_type.as_deref(),
555 Some(BILLING_ACCOUNT_TYPE_LOCAL)
556 );
557 assert_eq!(record.provider_name, "OpenAI");
559 assert_eq!(record.publisher_name, "OpenAI");
560 assert_eq!(
562 record.service_subcategory.as_deref(),
563 Some(SERVICE_SUBCATEGORY_GENERATIVE_AI)
564 );
565 assert_eq!(record.sku_meter.as_deref(), Some("input"));
567 assert_eq!(record.pricing_currency, DEFAULT_BILLING_CURRENCY);
568 assert_eq!(record.pricing_currency_effective_cost, Decimal::from(0));
569 assert_eq!(record.region_id, None);
571 assert_eq!(record.commitment_discount_id, None);
572 assert_eq!(record.tags, None);
573 }
574
575 #[test]
576 fn unpriced_usage_maps_time_columns() {
577 let record = record();
578
579 assert_eq!(record.charge_period_start, timestamp());
580 assert_eq!(record.charge_period_end, timestamp() + Duration::seconds(1));
581 assert_eq!(
582 record.billing_period_start.to_rfc3339(),
583 "2026-01-01T00:00:00+00:00"
584 );
585 assert_eq!(
586 record.billing_period_end.to_rfc3339(),
587 "2026-02-01T00:00:00+00:00"
588 );
589 }
590
591 #[test]
592 fn charge_period_start_is_truncated_to_whole_seconds() {
593 let mut input = UnpricedUsage {
594 timestamp: timestamp(),
595 tool: "codex".to_string(),
596 model: "m".to_string(),
597 token_type: TokenType::Input,
598 token_count: 10,
599 project: None,
600 access_path: FocusAccessPath::Api,
601 service_name: "s".to_string(),
602 service_provider_name: "p".to_string(),
603 host_provider_name: "p".to_string(),
604 invoice_issuer_name: "p".to_string(),
605 billing_currency: DEFAULT_BILLING_CURRENCY.to_string(),
606 };
607 input.timestamp = match timestamp().with_nanosecond(123_456_789) {
608 Some(value) => value,
609 None => panic!("nanosecond should be valid"),
610 };
611
612 let record = match FocusRecord::unpriced_usage(input) {
613 Ok(value) => value,
614 Err(err) => panic!("record should build: {err}"),
615 };
616
617 assert_eq!(record.charge_period_start.nanosecond(), 0);
618 assert_eq!(
619 record.charge_period_end,
620 record.charge_period_start + Duration::seconds(1)
621 );
622 }
623
624 #[test]
625 fn json_export_emits_numbers_not_quoted_decimals() {
626 let json = match to_json_string(vec![record()]) {
627 Ok(value) => value,
628 Err(err) => panic!("json should serialize: {err}"),
629 };
630 let value: serde_json::Value = match serde_json::from_str(&json) {
631 Ok(value) => value,
632 Err(err) => panic!("json should parse: {err}"),
633 };
634
635 assert_eq!(value["focusVersion"], FOCUS_VERSION);
636 assert!(value["rows"].is_array());
637 let row = &value["rows"][0];
638 assert!(row["BilledCost"].is_number(), "BilledCost must be a number");
640 assert!(row["ListUnitPrice"].is_null());
642 assert!(row["PricingQuantity"].is_null());
643 assert!(row["ConsumedQuantity"].is_null());
644 assert!(row["PricingUnit"].is_null());
645 assert!(row["PricingCategory"].is_null());
646 assert!(
648 row["x_ConsumedTokens"].is_number(),
649 "x_ConsumedTokens must be a number"
650 );
651 assert_eq!(row["x_ConsumedTokens"].as_f64(), Some(1500.0));
652 }
653
654 #[test]
655 fn csv_export_renders_numerics_with_decimal_point() {
656 let csv = match to_csv_string(&[record()]) {
657 Ok(value) => value,
658 Err(err) => panic!("csv should serialize: {err}"),
659 };
660 let header = match csv.lines().next() {
661 Some(value) => value,
662 None => panic!("csv should have a header"),
663 };
664 let data = match csv.lines().nth(1) {
665 Some(value) => value,
666 None => panic!("csv should have a data row"),
667 };
668 let columns: Vec<&str> = header.split(',').collect();
669 let values: Vec<&str> = data.split(',').collect();
670 let field = |name: &str| -> &str {
671 match columns.iter().position(|c| *c == name) {
672 Some(index) => values[index],
673 None => panic!("column {name} should exist"),
674 }
675 };
676
677 assert_eq!(field("BilledCost"), "0.0");
680 assert_eq!(field("x_ConsumedTokens"), "1500.0");
681 assert_eq!(field("ListUnitPrice"), "");
683 assert_eq!(field("ConsumedQuantity"), "");
684 assert_eq!(field("PricingQuantity"), "");
685 assert_eq!(field("PricingUnit"), "");
686 assert_eq!(field("PricingCategory"), "");
687 }
688
689 #[test]
690 fn priced_shape_serializes_token_unit_and_per_token_price() {
691 let mut record = record();
694 record.pricing_unit = Some(PRICING_UNIT_TOKENS.to_string());
695 record.pricing_category = Some(PRICING_CATEGORY_STANDARD.to_string());
696 record.pricing_quantity = Some(Decimal::from(1_500));
697 record.consumed_quantity = Some(Decimal::from(1_500));
698 record.list_unit_price = Some(Decimal::new(3, 7));
700
701 let csv = match to_csv_string(&[record]) {
702 Ok(value) => value,
703 Err(err) => panic!("csv should serialize: {err}"),
704 };
705 let header = match csv.lines().next() {
706 Some(value) => value,
707 None => panic!("csv should have a header"),
708 };
709 let data = match csv.lines().nth(1) {
710 Some(value) => value,
711 None => panic!("csv should have a data row"),
712 };
713 let columns: Vec<&str> = header.split(',').collect();
714 let values: Vec<&str> = data.split(',').collect();
715 let field = |name: &str| -> &str {
716 match columns.iter().position(|c| *c == name) {
717 Some(index) => values[index],
718 None => panic!("column {name} should exist"),
719 }
720 };
721
722 assert_eq!(field("PricingUnit"), "tokens");
723 assert_eq!(field("PricingCategory"), "Standard");
724 assert_eq!(field("PricingQuantity"), "1500.0");
726 assert_eq!(field("ConsumedQuantity"), "1500.0");
727 assert_eq!(field("ListUnitPrice"), "0.0000003");
729 }
730
731 #[test]
732 fn csv_header_carries_full_focus_column_set_then_custom_columns() {
733 let csv = match to_csv_string(&[record()]) {
734 Ok(value) => value,
735 Err(err) => panic!("csv should serialize: {err}"),
736 };
737 let header = match csv.lines().next() {
738 Some(value) => value,
739 None => panic!("csv should have a header"),
740 };
741 let fields: Vec<&str> = header.split(',').collect();
742
743 assert!(header.starts_with("BilledCost,EffectiveCost,ListCost,ContractedCost"));
744 for required in [
745 "BillingAccountId",
746 "BillingAccountName",
747 "BillingAccountType",
748 "ProviderName",
749 "PublisherName",
750 "ServiceSubcategory",
751 "SkuMeter",
752 "PricingCurrency",
753 ] {
754 assert!(fields.contains(&required), "missing column {required}");
755 }
756 assert!(header.ends_with(
757 "x_Model,x_TokenType,x_AccessPath,x_Estimated,x_Tool,x_Project,x_PricingStatus,x_ConsumedTokens"
758 ));
759 }
760}