1#[cfg(test)]
2use std::io::Read;
3use std::path::Path;
4
5use crate::money::{DecimalError, Micros};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct UsageRow {
13 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#[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 Unreadable,
43 MissingColumn(&'static str),
44 NotANumber(&'static str),
45 TooManyDecimals(&'static str),
46 InvalidCsv,
47 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
121pub 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
258fn 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
285pub 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}