Skip to main content

ci_bill/
usage.rs

1#[cfg(test)]
2use std::io::Read;
3use std::path::Path;
4
5use crate::money::{DecimalError, Micros};
6
7/// One row from a GitHub detailed usage report.
8///
9/// Money and quantity are [`Micros`]. They are summed as integers and rounded
10/// only when a report prints them.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct UsageRow {
13    /// 1-based physical line in the CSV file. The header is line 1.
14    pub line: u64,
15    pub date: String,
16    pub product: String,
17    pub sku: String,
18    pub quantity: Micros,
19    pub unit_type: String,
20    pub applied_cost_per_quantity: Micros,
21    pub gross: Micros,
22    pub discount: Micros,
23    pub net: Micros,
24    pub username: String,
25    pub organization: String,
26    pub repository: String,
27    pub workflow_path: String,
28}
29
30/// An input error while reading a usage report. Exit code 2.
31///
32/// Messages name the line and the column. They never include cell text.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct UsageError {
35    pub line: u64,
36    pub kind: UsageErrorKind,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum UsageErrorKind {
41    /// The file could not be opened. `line` is 0 because no row was read.
42    Unreadable,
43    MissingColumn(&'static str),
44    NotANumber(&'static str),
45    TooManyDecimals(&'static str),
46    InvalidCsv,
47    /// A running total overflowed `i64` micros. `line` is the row that caused
48    /// it, or 0 when the overflow is in a total summed across the whole
49    /// report and no single row is to blame. The `Display` impl drops the
50    /// line number in that case instead of printing the meaningless `line 0`.
51    AmountOverflow(&'static str),
52}
53
54impl std::fmt::Display for UsageError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self.kind {
57            UsageErrorKind::Unreadable => write!(f, "line {}: cannot read file", self.line),
58            UsageErrorKind::MissingColumn(column) => {
59                write!(f, "line {}: missing column {column}", self.line)
60            }
61            UsageErrorKind::NotANumber(column) => {
62                write!(f, "line {}: {column} is not a number", self.line)
63            }
64            UsageErrorKind::TooManyDecimals(column) => {
65                write!(
66                    f,
67                    "line {}: {column} has more than 6 decimal places",
68                    self.line
69                )
70            }
71            UsageErrorKind::InvalidCsv => write!(f, "line {}: invalid CSV", self.line),
72            UsageErrorKind::AmountOverflow(column) => {
73                if self.line == 0 {
74                    write!(f, "{column} total is out of range (sum of all rows)")
75                } else {
76                    write!(f, "line {}: {column} total is out of range", self.line)
77                }
78            }
79        }
80    }
81}
82
83impl std::error::Error for UsageError {}
84
85const EMPTY_WORKFLOW: &str = "(no workflow)";
86
87const COLUMNS: [&str; 13] = [
88    "date",
89    "product",
90    "sku",
91    "quantity",
92    "unit_type",
93    "applied_cost_per_quantity",
94    "gross_amount",
95    "discount_amount",
96    "net_amount",
97    "username",
98    "organization",
99    "repository",
100    "workflow_path",
101];
102
103pub fn read_usage(path: &Path) -> Result<Vec<UsageRow>, UsageError> {
104    let bytes = std::fs::read(path).map_err(|_| UsageError {
105        line: 0,
106        kind: UsageErrorKind::Unreadable,
107    })?;
108    parse_bytes(&bytes)
109}
110
111#[cfg(test)]
112fn read_reader<R: Read>(mut reader: R) -> Result<Vec<UsageRow>, UsageError> {
113    let mut bytes = Vec::new();
114    reader.read_to_end(&mut bytes).map_err(|_| UsageError {
115        line: 0,
116        kind: UsageErrorKind::Unreadable,
117    })?;
118    parse_bytes(&bytes)
119}
120
121/// Parse a usage CSV already in memory, the same way [`read_usage`] does after reading the file.
122pub fn parse_bytes(bytes: &[u8]) -> Result<Vec<UsageRow>, UsageError> {
123    let mut csv_reader = csv::ReaderBuilder::new()
124        .has_headers(true)
125        .trim(csv::Trim::All)
126        .from_reader(bytes);
127
128    let headers = csv_reader
129        .headers()
130        .map_err(|err| csv_error(err, bytes))?
131        .clone();
132    let header_line = record_line(&headers, bytes);
133    let indexes = column_indexes(&headers, header_line)?;
134
135    let mut rows = Vec::new();
136    for result in csv_reader.records() {
137        let record = result.map_err(|err| csv_error(err, bytes))?;
138        let line = record_line(&record, bytes);
139        rows.push(parse_row(&record, &indexes, line)?);
140    }
141    Ok(rows)
142}
143
144struct ColumnIndexes {
145    date: usize,
146    product: usize,
147    sku: usize,
148    quantity: usize,
149    unit_type: usize,
150    applied_cost_per_quantity: usize,
151    gross_amount: usize,
152    discount_amount: usize,
153    net_amount: usize,
154    username: usize,
155    organization: usize,
156    repository: usize,
157    workflow_path: usize,
158}
159
160fn column_indexes(headers: &csv::StringRecord, line: u64) -> Result<ColumnIndexes, UsageError> {
161    let index = |name| {
162        headers
163            .iter()
164            .position(|header| header.eq_ignore_ascii_case(name))
165            .ok_or(UsageError {
166                line,
167                kind: UsageErrorKind::MissingColumn(name),
168            })
169    };
170    Ok(ColumnIndexes {
171        date: index(COLUMNS[0])?,
172        product: index(COLUMNS[1])?,
173        sku: index(COLUMNS[2])?,
174        quantity: index(COLUMNS[3])?,
175        unit_type: index(COLUMNS[4])?,
176        applied_cost_per_quantity: index(COLUMNS[5])?,
177        gross_amount: index(COLUMNS[6])?,
178        discount_amount: index(COLUMNS[7])?,
179        net_amount: index(COLUMNS[8])?,
180        username: index(COLUMNS[9])?,
181        organization: index(COLUMNS[10])?,
182        repository: index(COLUMNS[11])?,
183        workflow_path: index(COLUMNS[12])?,
184    })
185}
186
187fn parse_row(
188    record: &csv::StringRecord,
189    indexes: &ColumnIndexes,
190    line: u64,
191) -> Result<UsageRow, UsageError> {
192    Ok(UsageRow {
193        line,
194        date: field(record, indexes.date, line)?.to_string(),
195        product: field(record, indexes.product, line)?.to_string(),
196        sku: field(record, indexes.sku, line)?.to_string(),
197        quantity: parse_decimal(field(record, indexes.quantity, line)?, line, "quantity")?,
198        unit_type: field(record, indexes.unit_type, line)?.to_string(),
199        applied_cost_per_quantity: parse_decimal(
200            field(record, indexes.applied_cost_per_quantity, line)?,
201            line,
202            "applied_cost_per_quantity",
203        )?,
204        gross: parse_decimal(
205            field(record, indexes.gross_amount, line)?,
206            line,
207            "gross_amount",
208        )?,
209        discount: parse_decimal(
210            field(record, indexes.discount_amount, line)?,
211            line,
212            "discount_amount",
213        )?,
214        net: parse_decimal(field(record, indexes.net_amount, line)?, line, "net_amount")?,
215        username: field(record, indexes.username, line)?.to_string(),
216        organization: field(record, indexes.organization, line)?.to_string(),
217        repository: field(record, indexes.repository, line)?.to_string(),
218        workflow_path: normalize_workflow(field(record, indexes.workflow_path, line)?),
219    })
220}
221
222fn field(record: &csv::StringRecord, index: usize, line: u64) -> Result<&str, UsageError> {
223    record.get(index).ok_or(UsageError {
224        line,
225        kind: UsageErrorKind::InvalidCsv,
226    })
227}
228
229fn parse_decimal(raw: &str, line: u64, column: &'static str) -> Result<Micros, UsageError> {
230    match Micros::parse(raw) {
231        Ok(value) => Ok(value),
232        Err(DecimalError::TooManyDecimals) => Err(UsageError {
233            line,
234            kind: UsageErrorKind::TooManyDecimals(column),
235        }),
236        Err(DecimalError::NotANumber) => Err(UsageError {
237            line,
238            kind: UsageErrorKind::NotANumber(column),
239        }),
240    }
241}
242
243fn normalize_workflow(raw: &str) -> String {
244    if raw.is_empty() {
245        EMPTY_WORKFLOW.to_string()
246    } else {
247        raw.to_string()
248    }
249}
250
251fn record_line(record: &csv::StringRecord, bytes: &[u8]) -> u64 {
252    record
253        .position()
254        .map(|position| physical_line(position.line(), position.byte(), bytes))
255        .unwrap_or(1)
256}
257
258/// Line number of a record, counting a `\r\n` row as one line.
259///
260/// The reader stores the line from before it consumes the `\n` of a `\r\n`
261/// terminator. That `\n` is still the byte at `offset`, so the stored line is
262/// one short. A `\n` terminator is already included in the stored line.
263fn physical_line(line: u64, offset: u64, bytes: &[u8]) -> u64 {
264    let Some(index) = usize::try_from(offset).ok() else {
265        return line;
266    };
267    if index > 0 && bytes.get(index) == Some(&b'\n') && bytes.get(index - 1) == Some(&b'\r') {
268        line.saturating_add(1)
269    } else {
270        line
271    }
272}
273
274fn csv_error(err: csv::Error, bytes: &[u8]) -> UsageError {
275    let line = err
276        .position()
277        .map(|position| physical_line(position.line(), position.byte(), bytes))
278        .unwrap_or(1);
279    UsageError {
280        line,
281        kind: UsageErrorKind::InvalidCsv,
282    }
283}
284
285/// `true` when the product column is GitHub Actions, ignoring ASCII case.
286pub fn is_actions_product(product: &str) -> bool {
287    product.eq_ignore_ascii_case("actions")
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    const HEADER: &str = "date,product,sku,quantity,unit_type,applied_cost_per_quantity,gross_amount,discount_amount,net_amount,username,organization,repository,workflow_path";
295
296    fn parse(csv_text: &str) -> Result<Vec<UsageRow>, UsageError> {
297        read_reader(csv_text.as_bytes())
298    }
299
300    fn usd(text: &str) -> Micros {
301        Micros::parse(text).unwrap()
302    }
303
304    #[test]
305    fn column_order_case_and_crlf_do_not_matter() {
306        let canonical = "\
307date,product,sku,quantity,unit_type,applied_cost_per_quantity,gross_amount,discount_amount,net_amount,username,organization,repository,workflow_path
3082026-09-01,actions,actions_macos,10,minutes,0.062,3,0.5,2.5,ada,example-org,example-org/sample-app,a.yml
3092026-09-02,Actions,actions_linux,4,Minutes,0.006,1.5,0,1.25,ada,example-org,example-org/sample-app,b.yml
310";
311        let shuffled = "\
312workflow_path,GROSS_AMOUNT,extra,net_amount,Unit_Type,quantity,SKU,Repository,Date,PRODUCT,username,organization,discount_amount,applied_cost_per_quantity\r\n\
313a.yml,3,ignore-me,2.5,minutes,10,actions_macos,example-org/sample-app,2026-09-01,actions,ada,example-org,0.5,0.062\r\n\
314b.yml,1.5,also-ignored,1.25,Minutes,4,actions_linux,example-org/sample-app,2026-09-02,Actions,ada,example-org,0,0.006\r\n";
315        let left = parse(canonical).unwrap();
316        let right = parse(shuffled).unwrap();
317        assert_eq!(left, right);
318        assert_eq!(left[0].net, usd("2.5"));
319        assert_eq!(left[0].gross, usd("3"));
320        assert_eq!(left[0].discount, usd("0.5"));
321        assert_eq!(left[0].applied_cost_per_quantity, usd("0.062"));
322        assert_eq!(left[0].quantity, usd("10"));
323        assert_eq!(left[0].date, "2026-09-01");
324        assert_eq!(left[0].product, "actions");
325        assert_eq!(left[0].username, "ada");
326        assert_eq!(left[0].organization, "example-org");
327        assert_eq!(left[0].repository, "example-org/sample-app");
328        assert_eq!(left[1].gross, usd("1.5"));
329        assert!(!left[0].sku.contains("ignore"));
330    }
331
332    #[test]
333    fn missing_column_names_the_column_and_line() {
334        let csv_text = "\
335date,product,sku,quantity,unit_type,applied_cost_per_quantity,gross_amount,discount_amount,username,organization,repository,workflow_path
3362026-09-01,actions,actions_linux,1,minutes,0.006,1,0,ada,example-org,example-org/app,SECRET_CELL_VALUE
337";
338        let err = parse(csv_text).unwrap_err();
339        assert_eq!(err.line, 1);
340        assert_eq!(err.kind, UsageErrorKind::MissingColumn("net_amount"));
341        let message = err.to_string();
342        assert_eq!(message, "line 1: missing column net_amount");
343        assert!(!message.contains("SECRET_CELL_VALUE"));
344    }
345
346    #[test]
347    fn each_required_column_is_named_when_missing() {
348        for column in COLUMNS {
349            let headers = COLUMNS
350                .iter()
351                .copied()
352                .filter(|name| *name != column)
353                .collect::<Vec<_>>()
354                .join(",");
355            let csv_text = format!("{headers}\nkeep-secret\n");
356            let err = parse(&csv_text).unwrap_err();
357            assert_eq!(err.kind, UsageErrorKind::MissingColumn(column), "{column}");
358            assert!(!err.to_string().contains("keep-secret"));
359        }
360    }
361
362    #[test]
363    fn non_numeric_value_reports_line_without_cell() {
364        let cases = [
365            ("quantity", "NOT_A_NUMBER"),
366            ("applied_cost_per_quantity", "NaN"),
367            ("gross_amount", "inf"),
368            ("discount_amount", "-Infinity"),
369            ("net_amount", "12abc34"),
370        ];
371        for (column, token) in cases {
372            let mut fields = vec![
373                "2026-09-01",
374                "actions",
375                "actions_linux",
376                "1",
377                "minutes",
378                "0.006",
379                "1",
380                "0",
381                "1",
382                "ada",
383                "example-org",
384                "example-org/app",
385                "a.yml",
386            ];
387            let index = match column {
388                "quantity" => 3,
389                "applied_cost_per_quantity" => 5,
390                "gross_amount" => 6,
391                "discount_amount" => 7,
392                "net_amount" => 8,
393                _ => panic!("column"),
394            };
395            fields[index] = token;
396            let csv_text = format!("{HEADER}\n{}\n", fields.join(","));
397            let err = parse(&csv_text).unwrap_err();
398            assert_eq!(err.line, 2, "{column}");
399            assert_eq!(err.kind, UsageErrorKind::NotANumber(column));
400            let message = err.to_string();
401            assert_eq!(message, format!("line 2: {column} is not a number"));
402            assert!(!message.contains(token));
403        }
404
405        let later = format!(
406            "{HEADER}\n\
4072026-09-01,actions,ok,1,minutes,1,1,0,1,ada,org,org/app,a.yml\n\
4082026-09-02,actions,ok,2,minutes,1,2,0,2,ada,org,org/app,a.yml\n\
4092026-09-03,actions,ok,BROKEN,minutes,1,3,0,3,ada,org,org/app,a.yml\n"
410        );
411        let err = parse(&later).unwrap_err();
412        assert_eq!(err.line, 4);
413        assert_eq!(err.kind, UsageErrorKind::NotANumber("quantity"));
414        assert!(!err.to_string().contains("BROKEN"));
415    }
416
417    #[test]
418    fn seven_decimals_report_the_line_without_the_cell() {
419        let tokens = [
420            ("quantity", "1.2345678"),
421            ("applied_cost_per_quantity", "0.0000001"),
422            ("gross_amount", "1e-7"),
423            ("discount_amount", "-0.0000001"),
424            ("net_amount", "12345678e-7"),
425        ];
426        for (column, token) in tokens {
427            let mut fields = vec![
428                "2026-09-01",
429                "actions",
430                "actions_linux",
431                "1",
432                "minutes",
433                "0.000001",
434                "1.234567",
435                "0",
436                "1.234567",
437                "ada",
438                "example-org",
439                "example-org/app",
440                "a.yml",
441            ];
442            let index = match column {
443                "quantity" => 3,
444                "applied_cost_per_quantity" => 5,
445                "gross_amount" => 6,
446                "discount_amount" => 7,
447                "net_amount" => 8,
448                _ => panic!("column"),
449            };
450            fields[index] = token;
451            let csv_text = format!("{HEADER}\n{}\n{}\n", fields.join(","), fields.join(","));
452            let err = parse(&csv_text).unwrap_err();
453            assert_eq!(err.line, 2, "{column} {token}");
454            assert_eq!(err.kind, UsageErrorKind::TooManyDecimals(column));
455            let message = err.to_string();
456            assert_eq!(
457                message,
458                format!("line 2: {column} has more than 6 decimal places")
459            );
460            assert!(!message.contains(token));
461            assert!(!message.contains("1.2345678"));
462        }
463    }
464
465    #[test]
466    fn crlf_decimal_error_uses_the_physical_line() {
467        let good = "2026-09-01,actions,actions_linux,1,minutes,0.000001,1.234567,0,1.234567,ada,example-org,example-org/app,a.yml";
468        let bad = "2026-09-01,actions,actions_linux,1.2345678,minutes,0.000001,1.234567,0,1.234567,ada,example-org,example-org/app,a.yml";
469        let first = format!("{HEADER}\r\n{bad}\r\n");
470        let err = parse(&first).unwrap_err();
471        assert_eq!(err.line, 2);
472        assert_eq!(err.kind, UsageErrorKind::TooManyDecimals("quantity"));
473        assert!(!err.to_string().contains("1.2345678"));
474
475        let second = format!("{HEADER}\r\n{good}\r\n{bad}\r\n");
476        let err = parse(&second).unwrap_err();
477        assert_eq!(err.line, 3);
478        assert_eq!(
479            err.to_string(),
480            "line 3: quantity has more than 6 decimal places"
481        );
482        assert!(!err.to_string().contains("1.2345678"));
483    }
484
485    #[test]
486    fn empty_numeric_cells_are_not_numbers_and_empty_text_is_kept() {
487        let csv_text = format!(
488            "{HEADER}\n\
4892026-09-01,actions,actions_linux,,minutes,0.006,1,0,1,ada,example-org,example-org/app,a.yml\n"
490        );
491        let err = parse(&csv_text).unwrap_err();
492        assert_eq!(err.kind, UsageErrorKind::NotANumber("quantity"));
493        assert_eq!(err.to_string(), "line 2: quantity is not a number");
494
495        for column in [
496            "applied_cost_per_quantity",
497            "gross_amount",
498            "discount_amount",
499            "net_amount",
500        ] {
501            let mut fields = [
502                "2026-09-01",
503                "actions",
504                "actions_linux",
505                "1",
506                "minutes",
507                "0.006",
508                "1",
509                "0",
510                "1",
511                "ada",
512                "org",
513                "org/app",
514                "a.yml",
515            ];
516            let index = match column {
517                "applied_cost_per_quantity" => 5,
518                "gross_amount" => 6,
519                "discount_amount" => 7,
520                "net_amount" => 8,
521                _ => panic!("column"),
522            };
523            fields[index] = "";
524            let csv_text = format!("{HEADER}\n{}\n", fields.join(","));
525            let err = parse(&csv_text).unwrap_err();
526            assert_eq!(err.kind, UsageErrorKind::NotANumber(column), "{column}");
527        }
528
529        let blanks = format!(
530            "{HEADER}\n\
531, ,actions_storage,0.5,gigabyte-hours,0.000336,0.000168,0.000168,0,,, ,   \n\
5322026-09-30,ACTIONS,actions_linux,1,MINUTES,0.006,1,0,1, ,Example-Org,example-org/other,ci.yml\n"
533        );
534        let rows = parse(&blanks).unwrap();
535        assert_eq!(rows[0].date, "");
536        assert_eq!(rows[0].product, "");
537        assert_eq!(rows[0].username, "");
538        assert_eq!(rows[0].organization, "");
539        assert_eq!(rows[0].repository, "");
540        assert_eq!(rows[0].workflow_path, "(no workflow)");
541        assert_eq!(rows[0].quantity, usd("0.5"));
542        assert_eq!(rows[0].net, Micros::ZERO);
543        assert_eq!(rows[0].discount, usd("0.000168"));
544        assert_eq!(rows[1].product, "ACTIONS");
545        assert_eq!(rows[1].username, "");
546        assert_eq!(rows[1].repository, "example-org/other");
547        assert_eq!(rows[1].workflow_path, "ci.yml");
548        assert!(is_actions_product(&rows[1].product));
549        assert!(!is_actions_product(&rows[0].product));
550        assert!(!is_actions_product("copilot"));
551        assert!(!is_actions_product("COPILOT"));
552        assert!(is_actions_product("Actions"));
553    }
554
555    #[test]
556    fn utf8_bom_is_accepted() {
557        let csv_text = format!(
558            "{HEADER}\n\
5592026-09-01,actions,actions_linux,1.5,minutes,0.006,0.5,0.25,0.25,ada,example-org,example-org/app,.github/workflows/ci.yml\n"
560        );
561        let mut bytes = vec![0xEF, 0xBB, 0xBF];
562        bytes.extend(csv_text.as_bytes());
563        let rows = read_reader(bytes.as_slice()).unwrap();
564        assert_eq!(rows.len(), 1);
565        assert_eq!(rows[0].sku, "actions_linux");
566        assert_eq!(rows[0].quantity, usd("1.5"));
567        assert_eq!(rows[0].net, usd("0.25"));
568        assert_eq!(rows[0].workflow_path, ".github/workflows/ci.yml");
569    }
570
571    #[test]
572    fn empty_workflow_path_uses_placeholder() {
573        let csv_text = format!(
574            "{HEADER}\n\
5752026-09-01,actions,actions_storage,0.5,gigabyte-hours,0.000336,0,0,0,ada,example-org,example-org/app,\n\
5762026-09-01,actions,actions_linux,1,minutes,0.006,1,0,1,ada,example-org,example-org/app,   \n"
577        );
578        let rows = parse(&csv_text).unwrap();
579        assert_eq!(rows.len(), 2);
580        assert_eq!(rows[0].workflow_path, "(no workflow)");
581        assert_eq!(rows[1].workflow_path, "(no workflow)");
582        assert_eq!(rows[0].unit_type, "gigabyte-hours");
583    }
584
585    #[test]
586    fn amount_overflow_with_a_known_line_names_it() {
587        let err = UsageError {
588            line: 3,
589            kind: UsageErrorKind::AmountOverflow("net_amount"),
590        };
591        assert_eq!(err.to_string(), "line 3: net_amount total is out of range");
592    }
593
594    #[test]
595    fn amount_overflow_with_no_known_line_drops_it() {
596        let err = UsageError {
597            line: 0,
598            kind: UsageErrorKind::AmountOverflow("net_amount"),
599        };
600        assert_eq!(
601            err.to_string(),
602            "net_amount total is out of range (sum of all rows)"
603        );
604        assert!(!err.to_string().contains("line 0"));
605    }
606
607    #[test]
608    fn unreadable_file_reports_line_zero() {
609        let err = read_usage(Path::new("/no/such/ci-bill-usage.csv")).unwrap_err();
610        assert_eq!(err.line, 0);
611        assert_eq!(err.kind, UsageErrorKind::Unreadable);
612        assert_eq!(err.to_string(), "line 0: cannot read file");
613    }
614
615    #[test]
616    fn sample_usage_report_parses() {
617        let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/usage-sample.csv");
618        let rows = read_usage(&path).unwrap();
619        assert_eq!(rows.len(), 150);
620        assert_eq!(
621            rows.iter()
622                .filter(|row| row.workflow_path == "(no workflow)")
623                .count(),
624            30
625        );
626        assert_eq!(rows[0].date, "2026-09-01");
627        assert_eq!(rows[0].product, "actions");
628        assert_eq!(rows[0].sku, "actions_macos");
629        assert_eq!(rows[0].quantity, usd("118"));
630        assert_eq!(rows[0].unit_type, "minutes");
631        assert_eq!(rows[0].applied_cost_per_quantity, usd("0.062"));
632        assert_eq!(rows[0].gross, usd("7.316"));
633        assert_eq!(rows[0].discount, Micros::ZERO);
634        assert_eq!(rows[0].net, usd("7.316"));
635        assert_eq!(rows[0].username, "sample-user");
636        assert_eq!(rows[0].organization, "example-org");
637        assert_eq!(rows[0].repository, "example-org/sample-app");
638        assert_eq!(rows[0].workflow_path, ".github/workflows/lint.yml");
639        assert_eq!(rows.last().unwrap().date, "2026-09-30");
640
641        let storage = &rows[4];
642        assert_eq!(storage.workflow_path, "(no workflow)");
643        assert_eq!(storage.sku, "actions_storage");
644        assert_eq!(storage.quantity, usd("0.5"));
645        assert_eq!(storage.unit_type, "gigabyte-hours");
646        assert_eq!(storage.applied_cost_per_quantity, usd("0.000336"));
647        assert_eq!(storage.gross, usd("0.000168"));
648        assert_eq!(storage.discount, usd("0.000168"));
649        assert_eq!(storage.net, Micros::ZERO);
650        assert_eq!(storage.username, "");
651        assert!(is_actions_product(&storage.product));
652    }
653}