1use std::collections::HashSet;
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use serde_json::{Map, Value};
24
25use crate::money::Micros;
26
27pub const IN_PROGRESS: &str = "in progress";
29
30pub const UNKNOWN_WORKFLOW: &str = "(unknown workflow)";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct WorkflowRun {
36 pub id: u64,
37 pub path: String,
39 pub repository: Option<String>,
42 pub run_attempt: u64,
43 pub run_number: u64,
44 pub event: String,
45 pub conclusion: Option<String>,
46 pub run_started_at: Option<String>,
47 pub head_branch: Option<String>,
49 pub pull_requests: Vec<u64>,
52}
53
54impl WorkflowRun {
55 pub fn links_to(&self, workflow_path: &str) -> bool {
57 workflow_paths_match(&self.path, workflow_path)
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct Job {
67 pub id: u64,
68 pub run_id: u64,
69 pub run_attempt: u64,
70 pub name: String,
71 pub labels: Vec<String>,
72 pub started_at: Option<String>,
73 pub completed_at: Option<String>,
74 pub conclusion: Option<String>,
75 pub runner_name: Option<String>,
76}
77
78impl Job {
79 pub fn counts(&self) -> bool {
84 self.completed_at
85 .as_deref()
86 .is_some_and(|text| !text.trim().is_empty())
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ApiData {
96 pub runs: Vec<WorkflowRun>,
97 pub jobs: Vec<Job>,
98 pub public: bool,
103}
104
105impl ApiData {
106 pub fn counted_jobs(&self) -> impl Iterator<Item = &Job> {
108 self.jobs.iter().filter(|job| job.counts())
109 }
110
111 pub fn in_progress_jobs(&self) -> impl Iterator<Item = &Job> {
113 self.jobs.iter().filter(|job| !job.counts())
114 }
115
116 pub fn workflow_path(&self, run_id: u64) -> String {
118 self.runs
119 .iter()
120 .find(|run| run.id == run_id)
121 .map(|run| linked_workflow_path(&run.path))
122 .unwrap_or_else(|| UNKNOWN_WORKFLOW.to_string())
123 }
124
125 pub fn is_inherited_copy(&self, job: &Job) -> bool {
131 let Some(run) = self.runs.iter().find(|run| run.id == job.run_id) else {
132 return false;
133 };
134 job.run_attempt == run.run_attempt && started_before_run(job, run)
135 }
136
137 pub fn inherited_copy_net(&self, job: &Job) -> Option<Micros> {
139 self.is_inherited_copy(job).then_some(Micros::ZERO)
140 }
141
142 pub fn billable_jobs(&self) -> impl Iterator<Item = &Job> {
144 self.jobs
145 .iter()
146 .filter(|job| job.counts() && !self.is_inherited_copy(job))
147 }
148}
149
150pub fn linked_workflow_path(raw: &str) -> String {
157 let trimmed = raw.trim().trim_matches('\u{feff}').trim_matches('\r');
158 let without_ref = trimmed
159 .split_once('@')
160 .map(|(path, _reference)| path)
161 .unwrap_or(trimmed);
162 let without_ref = without_ref.trim().trim_matches('\r');
163 let slashed = without_ref.replace('\\', "/");
164 strip_owner_repo_prefix(&slashed)
165}
166
167pub fn repository_from_run_path(raw: &str) -> Option<String> {
172 let trimmed = raw.trim().trim_matches('\u{feff}').trim_matches('\r');
173 let without_ref = trimmed
174 .split_once('@')
175 .map(|(path, _reference)| path)
176 .unwrap_or(trimmed);
177 let slashed = without_ref.trim().trim_matches('\r').replace('\\', "/");
178 let linked = strip_owner_repo_prefix(&slashed);
179 let prefix = slashed.trim_end_matches('/');
180 let linked = linked.trim_start_matches('/');
181 let rest = prefix.strip_suffix(linked)?.trim_end_matches('/');
182 if rest.is_empty() {
183 return None;
184 }
185 let parts: Vec<&str> = rest.split('/').filter(|part| !part.is_empty()).collect();
186 if parts.len() == 2 {
187 Some(format!("{}/{}", parts[0], parts[1]))
188 } else {
189 None
190 }
191}
192
193pub fn workflow_paths_match(left: &str, right: &str) -> bool {
195 let left = linked_workflow_path(left);
196 let right = linked_workflow_path(right);
197 !left.is_empty() && left.eq_ignore_ascii_case(&right)
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct ApiError {
205 pub path: PathBuf,
206 kind: ApiErrorKind,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210enum ApiErrorKind {
211 Directory,
212 File,
213 Json,
214 DuplicateId,
215 RunMismatch,
216}
217
218impl ApiError {
219 fn directory(path: &Path) -> Self {
220 Self {
221 path: path.to_path_buf(),
222 kind: ApiErrorKind::Directory,
223 }
224 }
225
226 fn file(path: &Path) -> Self {
227 Self {
228 path: path.to_path_buf(),
229 kind: ApiErrorKind::File,
230 }
231 }
232
233 fn json(path: &Path) -> Self {
234 Self {
235 path: path.to_path_buf(),
236 kind: ApiErrorKind::Json,
237 }
238 }
239
240 fn duplicate(path: &Path) -> Self {
241 Self {
242 path: path.to_path_buf(),
243 kind: ApiErrorKind::DuplicateId,
244 }
245 }
246
247 fn mismatch(path: &Path) -> Self {
248 Self {
249 path: path.to_path_buf(),
250 kind: ApiErrorKind::RunMismatch,
251 }
252 }
253}
254
255impl std::fmt::Display for ApiError {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 let message = match self.kind {
258 ApiErrorKind::Directory => "cannot read directory",
259 ApiErrorKind::File => "cannot read file",
260 ApiErrorKind::Json => "invalid JSON",
261 ApiErrorKind::DuplicateId => "duplicate id",
262 ApiErrorKind::RunMismatch => "run id does not match file name",
263 };
264 f.write_str(message)
265 }
266}
267
268impl std::error::Error for ApiError {}
269
270pub fn read_dir(dir: &Path) -> Result<ApiData, ApiError> {
278 let mut runs = Vec::new();
279 let mut jobs = Vec::new();
280 let mut seen_runs = HashSet::new();
281 let mut seen_jobs = HashSet::new();
282
283 for path in matching_files(dir)? {
284 let name = path
285 .file_name()
286 .and_then(|item| item.to_str())
287 .ok_or_else(|| ApiError::file(&path))?;
288 let text = read_text(&path)?;
289 if is_runs_file(name) {
290 let parsed = parse_runs(&text).map_err(|_| ApiError::json(&path))?;
291 for run in parsed {
292 if !seen_runs.insert(run.id) {
293 return Err(ApiError::duplicate(&path));
294 }
295 runs.push(run);
296 }
297 } else {
298 let file_run_id = job_file_run_id(name).ok_or_else(|| ApiError::file(&path))?;
299 let parsed = parse_jobs(&text).map_err(|_| ApiError::json(&path))?;
300 for job in parsed {
301 if job.run_id != file_run_id {
302 return Err(ApiError::mismatch(&path));
303 }
304 if !seen_jobs.insert(job.id) {
306 continue;
307 }
308 jobs.push(job);
309 }
310 }
311 }
312
313 runs.sort_by(cmp_run);
314 jobs.sort_by(cmp_job);
315 let public = read_public(dir)?;
316 Ok(ApiData { runs, jobs, public })
317}
318
319fn read_public(dir: &Path) -> Result<bool, ApiError> {
325 let path = dir.join("repo.json");
326 if !path.exists() {
327 return Ok(false);
328 }
329 if !path.is_file() {
330 return Err(ApiError::file(&path));
331 }
332 let text = read_text(&path)?;
333 let value: Value = serde_json::from_str(&text).map_err(|_| ApiError::json(&path))?;
334 let object = value.as_object().ok_or_else(|| ApiError::json(&path))?;
335 match private_flag(object) {
336 PrivateFlag::Absent => Ok(false),
337 PrivateFlag::Value(private) => Ok(!private),
338 PrivateFlag::Invalid => Err(ApiError::json(&path)),
339 }
340}
341
342enum PrivateFlag {
343 Absent,
344 Value(bool),
345 Invalid,
346}
347
348fn private_flag(object: &Map<String, Value>) -> PrivateFlag {
349 let value = object.get("private").or_else(|| {
350 object
351 .iter()
352 .find(|(key, _)| key.eq_ignore_ascii_case("private"))
353 .map(|(_, value)| value)
354 });
355 match value {
356 None | Some(Value::Null) => PrivateFlag::Absent,
357 Some(Value::Bool(private)) => PrivateFlag::Value(*private),
358 Some(Value::String(text)) => match text.trim() {
359 "" => PrivateFlag::Absent,
360 word if word.eq_ignore_ascii_case("true") => PrivateFlag::Value(true),
361 word if word.eq_ignore_ascii_case("false") => PrivateFlag::Value(false),
362 _ => PrivateFlag::Invalid,
363 },
364 Some(_) => PrivateFlag::Invalid,
365 }
366}
367
368fn matching_files(dir: &Path) -> Result<Vec<PathBuf>, ApiError> {
369 let entries = fs::read_dir(dir).map_err(|_| ApiError::directory(dir))?;
370 let mut paths = Vec::new();
371 for entry in entries {
372 let entry = entry.map_err(|_| ApiError::directory(dir))?;
373 let path = entry.path();
374 let Some(name) = path.file_name().and_then(|item| item.to_str()) else {
375 continue;
376 };
377 if !is_runs_file(name) && !is_filter_all_jobs_name(name) {
378 continue;
379 }
380 if !path.is_file() {
381 return Err(ApiError::file(&path));
382 }
383 paths.push(path);
384 }
385 paths.sort();
386 Ok(paths)
387}
388
389fn read_text(path: &Path) -> Result<String, ApiError> {
390 let bytes = fs::read(path).map_err(|_| ApiError::file(path))?;
391 let without_bom = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes);
392 String::from_utf8(without_bom.to_vec()).map_err(|_| ApiError::json(path))
393}
394
395pub(crate) fn is_runs_file(name: &str) -> bool {
396 let Some(middle) = name
397 .strip_prefix("runs-")
398 .and_then(|rest| rest.strip_suffix(".json"))
399 else {
400 return false;
401 };
402 !middle.is_empty()
403}
404
405fn is_filter_all_jobs_name(name: &str) -> bool {
412 if name_has_attempt_segment(name) {
413 return false;
414 }
415 job_file_run_id(name).is_some()
416}
417
418fn name_has_attempt_segment(name: &str) -> bool {
419 name.split(|character: char| !character.is_ascii_alphanumeric())
420 .any(|part| part.eq_ignore_ascii_case("attempt") || part.eq_ignore_ascii_case("attempts"))
421}
422
423pub(crate) fn job_file_run_id(name: &str) -> Option<u64> {
428 let rest = name.strip_prefix("jobs-")?.strip_suffix(".json")?;
429 if rest.is_empty() {
430 return None;
431 }
432 let end = rest
433 .find(|c: char| !c.is_ascii_digit())
434 .unwrap_or(rest.len());
435 if end == 0 {
436 return None;
437 }
438 rest[..end].parse().ok()
439}
440
441#[allow(clippy::result_unit_err)]
443pub fn parse_runs(text: &str) -> Result<Vec<WorkflowRun>, ()> {
444 let value: Value = serde_json::from_str(text).map_err(|_| ())?;
445 let items = value
446 .get("workflow_runs")
447 .and_then(Value::as_array)
448 .ok_or(())?;
449 items.iter().map(parse_run).collect()
450}
451
452fn parse_run(value: &Value) -> Result<WorkflowRun, ()> {
453 let object = value.as_object().ok_or(())?;
454 let raw_path = require_string(object, "path")?;
455 Ok(WorkflowRun {
456 id: require_u64(object, "id")?,
457 path: linked_workflow_path(&raw_path),
458 repository: repository_from_run_path(&raw_path),
459 run_attempt: require_u64(object, "run_attempt")?,
460 run_number: require_u64(object, "run_number")?,
461 event: require_string(object, "event")?,
462 conclusion: optional_string(object, "conclusion")?,
463 run_started_at: optional_string(object, "run_started_at")?,
464 head_branch: optional_string(object, "head_branch")?,
465 pull_requests: optional_pull_requests(object)?,
466 })
467}
468
469fn optional_pull_requests(object: &Map<String, Value>) -> Result<Vec<u64>, ()> {
470 match object.get("pull_requests") {
471 None | Some(Value::Null) => Ok(Vec::new()),
472 Some(Value::Array(items)) => {
473 let mut numbers = Vec::new();
474 for item in items {
475 match item {
476 Value::Null => {}
477 Value::Object(fields) => {
478 if let Some(number) = pull_number(fields)? {
479 numbers.push(number);
480 }
481 }
482 _ => return Err(()),
483 }
484 }
485 Ok(numbers)
486 }
487 Some(_) => Err(()),
488 }
489}
490
491fn pull_number(fields: &Map<String, Value>) -> Result<Option<u64>, ()> {
492 match fields.get("number") {
493 None | Some(Value::Null) => Ok(None),
494 Some(Value::Number(number)) => number.as_u64().map(Some).ok_or(()),
495 Some(_) => Err(()),
496 }
497}
498
499#[allow(clippy::result_unit_err)]
501pub fn parse_jobs(text: &str) -> Result<Vec<Job>, ()> {
502 let value: Value = serde_json::from_str(text).map_err(|_| ())?;
503 let items = value.get("jobs").and_then(Value::as_array).ok_or(())?;
504 items.iter().map(parse_job).collect()
505}
506
507fn parse_job(value: &Value) -> Result<Job, ()> {
508 let object = value.as_object().ok_or(())?;
509 Ok(Job {
510 id: require_u64(object, "id")?,
511 run_id: require_u64(object, "run_id")?,
512 run_attempt: require_u64(object, "run_attempt")?,
513 name: require_string(object, "name")?,
514 labels: optional_labels(object)?,
515 started_at: optional_string(object, "started_at")?,
516 completed_at: optional_string(object, "completed_at")?,
517 conclusion: optional_string(object, "conclusion")?,
518 runner_name: optional_string(object, "runner_name")?,
519 })
520}
521
522fn require_u64(object: &Map<String, Value>, key: &str) -> Result<u64, ()> {
523 object.get(key).and_then(Value::as_u64).ok_or(())
524}
525
526fn require_string(object: &Map<String, Value>, key: &str) -> Result<String, ()> {
527 object
528 .get(key)
529 .and_then(Value::as_str)
530 .map(str::to_owned)
531 .ok_or(())
532}
533
534fn optional_string(object: &Map<String, Value>, key: &str) -> Result<Option<String>, ()> {
535 match object.get(key) {
536 None | Some(Value::Null) => Ok(None),
537 Some(Value::String(text)) => Ok(Some(text.clone())),
538 Some(_) => Err(()),
539 }
540}
541
542fn optional_labels(object: &Map<String, Value>) -> Result<Vec<String>, ()> {
543 match object.get("labels") {
544 None | Some(Value::Null) => Ok(Vec::new()),
545 Some(Value::Array(items)) => items
546 .iter()
547 .map(|item| item.as_str().map(str::to_owned).ok_or(()))
548 .collect(),
549 Some(_) => Err(()),
550 }
551}
552
553fn strip_owner_repo_prefix(path: &str) -> String {
554 let Some(index) = find_workflows_marker(path) else {
555 return path.to_string();
556 };
557 let prefix = path[..index].trim_end_matches('/');
558 if prefix.is_empty() {
559 return path[index..].to_string();
560 }
561 let parts: Vec<&str> = prefix.split('/').filter(|part| !part.is_empty()).collect();
562 if parts.len() == 2 {
563 path[index..].to_string()
564 } else {
565 path.to_string()
566 }
567}
568
569fn find_workflows_marker(path: &str) -> Option<usize> {
570 let bytes = path.as_bytes();
571 const NEEDLE: &[u8] = b".github/workflows";
572 if bytes.len() < NEEDLE.len() {
573 return None;
574 }
575 let last = bytes.len() - NEEDLE.len();
576 let mut index = 0;
577 while index <= last {
578 if eq_ignore_ascii(&bytes[index..index + NEEDLE.len()], NEEDLE) {
579 let at_boundary = index == 0 || bytes[index - 1] == b'/';
580 let after = index + NEEDLE.len();
581 let at_end = after == bytes.len() || bytes[after] == b'/';
582 if at_boundary && at_end {
583 return Some(index);
584 }
585 }
586 index += 1;
587 }
588 None
589}
590
591fn eq_ignore_ascii(left: &[u8], right: &[u8]) -> bool {
592 left.len() == right.len()
593 && left
594 .iter()
595 .zip(right)
596 .all(|(left_byte, right_byte)| left_byte.eq_ignore_ascii_case(right_byte))
597}
598
599fn started_before_run(job: &Job, run: &WorkflowRun) -> bool {
600 let Some(started) = job.started_at.as_deref().and_then(parse_github_time) else {
601 return false;
602 };
603 let Some(run_started) = run.run_started_at.as_deref().and_then(parse_github_time) else {
604 return false;
605 };
606 started < run_started
607}
608
609pub(crate) fn parse_github_time(raw: &str) -> Option<i128> {
611 let text = raw.trim().trim_matches('\r');
612 let bytes = text.as_bytes();
613 if bytes.len() < 20 {
614 return None;
615 }
616 if bytes[4] != b'-' || bytes[7] != b'-' || bytes[13] != b':' || bytes[16] != b':' {
617 return None;
618 }
619 let year = parse_digits(&bytes[0..4])?;
620 let month = parse_digits(&bytes[5..7])?;
621 let day = parse_digits(&bytes[8..10])?;
622 if !matches!(bytes[10], b'T' | b't' | b' ') {
623 return None;
624 }
625 let hour = parse_digits(&bytes[11..13])?;
626 let minute = parse_digits(&bytes[14..16])?;
627 let second = parse_digits(&bytes[17..19])?;
628 if !valid_ymd(year, month, day) || hour > 23 || minute > 59 || second > 59 {
629 return None;
630 }
631 let mut index = 19;
632 let mut nanos: i128 = 0;
633 if index < bytes.len() && bytes[index] == b'.' {
634 index += 1;
635 let start = index;
636 while index < bytes.len() && bytes[index].is_ascii_digit() {
637 index += 1;
638 }
639 if index == start {
640 return None;
641 }
642 nanos = fraction_nanos(&text[start..index])?;
643 }
644 let offset_minutes = parse_offset(&bytes[index..])?;
645 let days = days_from_civil(i32::try_from(year).ok()?, month, day);
646 let seconds = days
647 .checked_mul(86_400)?
648 .checked_add(i64::from(hour) * 3_600)?
649 .checked_add(i64::from(minute) * 60)?
650 .checked_add(i64::from(second))?;
651 let utc = seconds.checked_sub(offset_minutes.checked_mul(60)?)?;
652 Some(i128::from(utc) * 1_000_000_000 + nanos)
653}
654
655fn fraction_nanos(digits: &str) -> Option<i128> {
656 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
657 return None;
658 }
659 let mut padded = String::new();
660 for (index, byte) in digits.bytes().enumerate() {
661 if index == 9 {
662 break;
663 }
664 padded.push(char::from(byte));
665 }
666 while padded.len() < 9 {
667 padded.push('0');
668 }
669 padded.parse().ok()
670}
671
672fn parse_offset(bytes: &[u8]) -> Option<i64> {
673 if bytes == b"Z" || bytes == b"z" {
674 return Some(0);
675 }
676 if bytes.is_empty() {
677 return None;
678 }
679 let sign: i64 = match bytes[0] {
680 b'+' => 1,
681 b'-' => -1,
682 _ => return None,
683 };
684 let rest = &bytes[1..];
685 let (hours, minutes) = match rest.len() {
686 2 => (parse_digits(rest)?, 0),
687 4 => (parse_digits(&rest[0..2])?, parse_digits(&rest[2..4])?),
688 5 => {
689 if rest[2] != b':' {
690 return None;
691 }
692 (parse_digits(&rest[0..2])?, parse_digits(&rest[3..5])?)
693 }
694 _ => return None,
695 };
696 if hours > 23 || minutes > 59 {
697 return None;
698 }
699 Some(sign * (i64::from(hours) * 60 + i64::from(minutes)))
700}
701
702fn parse_digits(bytes: &[u8]) -> Option<u32> {
703 if bytes.is_empty() {
704 return None;
705 }
706 let mut value: u32 = 0;
707 for byte in bytes {
708 let digit = byte.checked_sub(b'0')?;
709 if digit > 9 {
710 return None;
711 }
712 value = value.checked_mul(10)?.checked_add(u32::from(digit))?;
713 }
714 Some(value)
715}
716
717fn valid_ymd(year: u32, month: u32, day: u32) -> bool {
718 if !(1..=12).contains(&month) || day < 1 {
719 return false;
720 }
721 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
722 let max = match month {
723 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
724 4 | 6 | 9 | 11 => 30,
725 2 if leap => 29,
726 2 => 28,
727 _ => 0,
728 };
729 day <= max
730}
731
732fn days_from_civil(year: i32, month: u32, day: u32) -> i64 {
734 let mut y = i64::from(year);
735 if month <= 2 {
736 y -= 1;
737 }
738 let era = if y >= 0 { y } else { y - 399 } / 400;
739 let yoe = u64::try_from(y - era * 400).unwrap_or(0);
740 let month_adj = if month > 2 { month - 3 } else { month + 9 };
741 let doy = (153 * month_adj + 2) / 5 + day - 1;
742 let doe = yoe * 365 + yoe / 4 - yoe / 100 + u64::from(doy);
743 era * 146097 + i64::try_from(doe).unwrap_or(0) - 719468
744}
745
746fn cmp_run(left: &WorkflowRun, right: &WorkflowRun) -> std::cmp::Ordering {
747 left.path
748 .cmp(&right.path)
749 .then(left.run_number.cmp(&right.run_number))
750 .then(left.run_attempt.cmp(&right.run_attempt))
751 .then(left.id.cmp(&right.id))
752}
753
754fn cmp_job(left: &Job, right: &Job) -> std::cmp::Ordering {
755 left.run_id
756 .cmp(&right.run_id)
757 .then(left.name.cmp(&right.name))
758 .then(left.run_attempt.cmp(&right.run_attempt))
759 .then(left.id.cmp(&right.id))
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 fn scratch(name: &str) -> PathBuf {
767 let path = std::env::temp_dir().join("ci-bill-api-unit").join(name);
768 let _ = fs::remove_dir_all(&path);
769 fs::create_dir_all(&path).unwrap();
770 path
771 }
772
773 fn write_json(dir: &Path, name: &str, value: &Value) {
774 fs::write(dir.join(name), serde_json::to_vec_pretty(value).unwrap()).unwrap();
775 }
776
777 fn run_value(id: u64, path: &str) -> Value {
778 serde_json::json!({
779 "id": id,
780 "name": "CI",
781 "path": path,
782 "run_attempt": 1,
783 "run_number": id,
784 "event": "push",
785 "status": "completed",
786 "conclusion": "success",
787 "run_started_at": "2026-03-01T00:00:00Z",
788 "actor": {"login": "dev", "id": 1},
789 "previous_attempt_url": null
790 })
791 }
792
793 fn job_value(id: u64, run_id: u64, name: &str) -> Value {
794 serde_json::json!({
795 "id": id,
796 "run_id": run_id,
797 "run_attempt": 1,
798 "name": name,
799 "status": "completed",
800 "conclusion": "success",
801 "started_at": "2026-03-01T00:00:00Z",
802 "completed_at": "2026-03-01T00:01:00Z",
803 "labels": ["ubuntu-latest"],
804 "runner_name": "runner-a",
805 "runner_id": 4,
806 "steps": [{"name": "checkout", "conclusion": "success"}]
807 })
808 }
809
810 fn runs_body(runs: Vec<Value>) -> Value {
811 serde_json::json!({"total_count": runs.len(), "workflow_runs": runs})
812 }
813
814 fn jobs_body(jobs: Vec<Value>) -> Value {
815 serde_json::json!({"total_count": jobs.len(), "jobs": jobs})
816 }
817
818 fn fixture_dir() -> PathBuf {
819 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/api")
820 }
821
822 #[test]
823 fn in_progress_phrase_is_stable() {
824 assert_eq!(IN_PROGRESS, "in progress");
825 assert_eq!(UNKNOWN_WORKFLOW, "(unknown workflow)");
826 }
827
828 #[test]
829 fn job_file_names_use_the_leading_run_id() {
830 assert_eq!(job_file_run_id("jobs-15.json"), Some(15));
831 assert_eq!(job_file_run_id("jobs-15-2.json"), Some(15));
832 assert_eq!(job_file_run_id("jobs-15_page3.json"), Some(15));
833 assert_eq!(job_file_run_id("jobs-15-page-3.json"), Some(15));
834 assert_eq!(job_file_run_id("jobs-015.json"), Some(15));
835 assert_eq!(job_file_run_id("jobs-150.json"), Some(150));
836 assert_eq!(job_file_run_id("jobs-150-2.json"), Some(150));
837 assert_eq!(job_file_run_id("jobs-0.json"), Some(0));
838 assert_eq!(job_file_run_id("jobs-.json"), None);
839 assert_eq!(job_file_run_id("jobs-x.json"), None);
840 assert_eq!(job_file_run_id("jobs-15.txt"), None);
841 assert_eq!(job_file_run_id("job-15.json"), None);
842 assert_eq!(job_file_run_id("Jobs-15.json"), None);
843 assert_eq!(job_file_run_id("jobs-15.json.bak"), None);
844 assert_eq!(job_file_run_id("runs-15.json"), None);
845 assert!(is_runs_file("runs-1.json"));
846 assert!(is_runs_file("runs-02.json"));
847 assert!(is_runs_file("runs-page-3.json"));
848 assert!(!is_runs_file("runs-.json"));
849 assert!(!is_runs_file("runs.json"));
850 assert!(!is_runs_file("runs-1.JSON"));
851 assert!(!is_runs_file("runs-1.txt"));
852 assert!(!is_runs_file("my-runs-1.json"));
853 }
854
855 #[test]
856 fn fixture_pages_are_read() {
857 let readme = fs::read_to_string(fixture_dir().join("README.md")).unwrap();
858 assert!(
859 readme.to_ascii_lowercase().contains("sample"),
860 "fixture README should mark the data as sample"
861 );
862
863 let data = read_dir(&fixture_dir()).unwrap();
864 assert_eq!(
865 data.runs,
866 vec![
867 WorkflowRun {
868 id: 1001,
869 path: ".github/workflows/ci.yml".to_string(),
870 repository: None,
871 run_attempt: 1,
872 run_number: 40,
873 event: "push".to_string(),
874 conclusion: Some("success".to_string()),
875 run_started_at: Some("2026-03-01T10:00:00Z".to_string()),
876 head_branch: Some("main".to_string()),
877 pull_requests: Vec::new(),
878 },
879 WorkflowRun {
880 id: 1002,
881 path: ".github/workflows/lint.yml".to_string(),
882 repository: None,
883 run_attempt: 2,
884 run_number: 41,
885 event: "pull_request".to_string(),
886 conclusion: None,
887 run_started_at: Some("2026-03-02T11:00:00Z".to_string()),
888 head_branch: None,
889 pull_requests: Vec::new(),
890 },
891 WorkflowRun {
892 id: 1003,
893 path: ".github/workflows/release.yml".to_string(),
894 repository: None,
895 run_attempt: 1,
896 run_number: 7,
897 event: "release".to_string(),
898 conclusion: Some("failure".to_string()),
899 run_started_at: Some("2026-03-03T12:00:00Z".to_string()),
900 head_branch: None,
901 pull_requests: Vec::new(),
902 },
903 ]
904 );
905 assert_eq!(
906 data.jobs,
907 vec![
908 Job {
909 id: 501,
910 run_id: 1001,
911 run_attempt: 1,
912 name: "build".to_string(),
913 labels: vec!["ubuntu-latest".to_string()],
914 started_at: Some("2026-03-01T10:00:05Z".to_string()),
915 completed_at: Some("2026-03-01T10:02:05Z".to_string()),
916 conclusion: Some("success".to_string()),
917 runner_name: Some("GitHub Actions 88".to_string()),
918 },
919 Job {
920 id: 502,
921 run_id: 1001,
922 run_attempt: 1,
923 name: "test".to_string(),
924 labels: vec!["ubuntu-latest".to_string(), "linux".to_string()],
925 started_at: Some("2026-03-01T10:02:06Z".to_string()),
926 completed_at: Some("2026-03-01T10:04:00Z".to_string()),
927 conclusion: Some("success".to_string()),
928 runner_name: Some("GitHub Actions 89".to_string()),
929 },
930 Job {
931 id: 602,
932 run_id: 1002,
933 run_attempt: 2,
934 name: "fmt".to_string(),
935 labels: vec!["ubuntu-latest".to_string()],
936 started_at: Some("2026-03-02T11:00:12Z".to_string()),
937 completed_at: Some("2026-03-02T11:01:12Z".to_string()),
938 conclusion: Some("success".to_string()),
939 runner_name: Some("GitHub Actions 91".to_string()),
940 },
941 Job {
942 id: 601,
943 run_id: 1002,
944 run_attempt: 2,
945 name: "lint".to_string(),
946 labels: vec!["macos-latest".to_string()],
947 started_at: Some("2026-03-02T11:00:10Z".to_string()),
948 completed_at: None,
949 conclusion: None,
950 runner_name: Some("GitHub Actions 90".to_string()),
951 },
952 Job {
953 id: 701,
954 run_id: 1003,
955 run_attempt: 1,
956 name: "publish".to_string(),
957 labels: vec!["windows-latest".to_string()],
958 started_at: Some("2026-03-03T12:00:01Z".to_string()),
959 completed_at: Some("2026-03-03T12:08:01Z".to_string()),
960 conclusion: Some("failure".to_string()),
961 runner_name: None,
962 },
963 ]
964 );
965
966 let counted: Vec<u64> = data.counted_jobs().map(|job| job.id).collect();
967 let running: Vec<u64> = data.in_progress_jobs().map(|job| job.id).collect();
968 assert_eq!(counted, vec![501, 502, 602, 701]);
969 assert_eq!(running, vec![601]);
970 let lint = data.jobs.iter().find(|job| job.id == 601).unwrap();
971 assert!(!lint.counts());
972 assert!(lint.completed_at.is_none());
973 assert_eq!(data.workflow_path(1002), ".github/workflows/lint.yml");
974 assert!(data
975 .jobs
976 .iter()
977 .any(|job| job.counts() && job.name == "fmt"));
978 }
979
980 #[test]
981 fn pagination_merges_every_page() {
982 let dir = scratch("pages");
983 write_json(
984 &dir,
985 "runs-2.json",
986 &runs_body(vec![run_value(2, ".github/workflows/b.yml")]),
987 );
988 write_json(
989 &dir,
990 "runs-10.json",
991 &runs_body(vec![run_value(10, ".github/workflows/c.yml")]),
992 );
993 write_json(
994 &dir,
995 "runs-page-3.json",
996 &runs_body(vec![run_value(3, ".github/workflows/a.yml")]),
997 );
998 write_json(&dir, "runs-9.json", &runs_body(vec![]));
999 write_json(
1000 &dir,
1001 "jobs-2.json",
1002 &jobs_body(vec![job_value(20, 2, "build")]),
1003 );
1004 write_json(
1005 &dir,
1006 "jobs-2-2.json",
1007 &jobs_body(vec![job_value(21, 2, "test")]),
1008 );
1009 let mut queued = job_value(22, 2, "lint");
1010 queued["completed_at"] = Value::Null;
1011 queued["conclusion"] = Value::Null;
1012 write_json(&dir, "jobs-2_page3.json", &jobs_body(vec![queued]));
1013 write_json(&dir, "jobs-10.json", &jobs_body(vec![]));
1014 write_json(
1015 &dir,
1016 "jobs-3.json",
1017 &jobs_body(vec![job_value(30, 3, "pack")]),
1018 );
1019
1020 let nested = dir.join("nested");
1021 fs::create_dir(&nested).unwrap();
1022 fs::write(nested.join("runs-1.json"), b"{").unwrap();
1023 fs::write(dir.join("README.md"), "sample notes {").unwrap();
1024 fs::write(dir.join("notes.json"), b"{").unwrap();
1025 fs::write(dir.join("runs.json"), b"{").unwrap();
1026 fs::write(dir.join("runs-1.txt"), b"{").unwrap();
1027
1028 let data = read_dir(&dir).unwrap();
1029 let run_ids: Vec<u64> = data.runs.iter().map(|run| run.id).collect();
1030 assert_eq!(run_ids, vec![3, 2, 10]);
1031 assert_eq!(
1032 data.runs[0].path, ".github/workflows/a.yml",
1033 "path order, not file-name order"
1034 );
1035 let job_ids: Vec<u64> = data.jobs.iter().map(|job| job.id).collect();
1036 assert_eq!(job_ids, vec![20, 22, 21, 30]);
1037 assert_eq!(
1038 data.counted_jobs().map(|job| job.id).collect::<Vec<_>>(),
1039 vec![20, 21, 30]
1040 );
1041 assert_eq!(
1042 data.in_progress_jobs()
1043 .map(|job| job.id)
1044 .collect::<Vec<_>>(),
1045 vec![22]
1046 );
1047 assert!(!data.jobs.iter().find(|job| job.id == 22).unwrap().counts());
1048 }
1049
1050 #[test]
1051 fn jobs_150_is_a_different_run_from_jobs_15() {
1052 let dir = scratch("id-boundary");
1053 write_json(
1054 &dir,
1055 "jobs-15.json",
1056 &jobs_body(vec![job_value(1, 15, "one")]),
1057 );
1058 write_json(
1059 &dir,
1060 "jobs-15-2.json",
1061 &jobs_body(vec![job_value(2, 15, "two")]),
1062 );
1063 write_json(
1064 &dir,
1065 "jobs-15_page3.json",
1066 &jobs_body(vec![job_value(3, 15, "three")]),
1067 );
1068 write_json(
1069 &dir,
1070 "jobs-150.json",
1071 &jobs_body(vec![job_value(4, 150, "wide")]),
1072 );
1073 write_json(
1074 &dir,
1075 "jobs-150-2.json",
1076 &jobs_body(vec![job_value(5, 150, "wider")]),
1077 );
1078
1079 let data = read_dir(&dir).unwrap();
1080 let for_15: Vec<u64> = data
1081 .jobs
1082 .iter()
1083 .filter(|job| job.run_id == 15)
1084 .map(|job| job.id)
1085 .collect();
1086 let for_150: Vec<u64> = data
1087 .jobs
1088 .iter()
1089 .filter(|job| job.run_id == 150)
1090 .map(|job| job.id)
1091 .collect();
1092 assert_eq!(for_15, vec![1, 3, 2]);
1093 assert_eq!(for_150, vec![4, 5]);
1094 assert!(data.runs.is_empty());
1095 assert_eq!(data.workflow_path(15), UNKNOWN_WORKFLOW);
1096 }
1097
1098 #[test]
1099 fn optional_fields_null_or_absent_follow_completed_at() {
1100 let dir = scratch("optional");
1101 let mut runs = vec![
1102 run_value(100, ".github/workflows/ci.yml"),
1103 run_value(101, ".github/workflows/lint.yml"),
1104 run_value(102, ".github/workflows/docs.yml"),
1105 run_value(103, ".github/workflows/release.yml"),
1106 ];
1107 runs[1]["conclusion"] = Value::Null;
1108 runs[2]["run_started_at"] = Value::Null;
1109 runs[2]["conclusion"] = serde_json::json!("success");
1110 runs[3].as_object_mut().unwrap().remove("run_started_at");
1111 runs[3].as_object_mut().unwrap().remove("conclusion");
1112 write_json(&dir, "runs-1.json", &runs_body(runs));
1113
1114 let mut jobs = Vec::new();
1115 let mut push = |id: u64, name: &str, edit: fn(&mut Value)| {
1116 let mut job = job_value(id, 100, name);
1117 edit(&mut job);
1118 jobs.push(job);
1119 };
1120 push(1, "full", |_| {});
1121 push(2, "null-completed", |job| {
1122 job["completed_at"] = Value::Null;
1123 job["conclusion"] = Value::Null;
1124 job["status"] = serde_json::json!("completed");
1125 });
1126 push(3, "absent-completed", |job| {
1127 let object = job.as_object_mut().unwrap();
1128 object.remove("completed_at");
1129 object.remove("conclusion");
1130 object.remove("started_at");
1131 });
1132 push(4, "null-conclusion", |job| {
1133 job["conclusion"] = Value::Null;
1134 });
1135 push(5, "absent-conclusion", |job| {
1136 job.as_object_mut().unwrap().remove("conclusion");
1137 });
1138 push(6, "null-runner", |job| {
1139 job["runner_name"] = Value::Null;
1140 });
1141 push(7, "absent-runner", |job| {
1142 job.as_object_mut().unwrap().remove("runner_name");
1143 });
1144 push(8, "null-started", |job| {
1145 job["started_at"] = Value::Null;
1146 });
1147 push(9, "absent-started", |job| {
1148 job.as_object_mut().unwrap().remove("started_at");
1149 });
1150 push(10, "null-labels", |job| {
1151 job["labels"] = Value::Null;
1152 });
1153 push(11, "absent-labels", |job| {
1154 job.as_object_mut().unwrap().remove("labels");
1155 });
1156 push(12, "empty-labels", |job| {
1157 job["labels"] = serde_json::json!([]);
1158 });
1159 push(13, "status-running-but-finished", |job| {
1160 job["status"] = serde_json::json!("in_progress");
1161 });
1162 push(14, "status-done-but-open", |job| {
1163 job["status"] = serde_json::json!("completed");
1164 job["completed_at"] = Value::Null;
1165 });
1166 write_json(&dir, "jobs-100.json", &jobs_body(jobs));
1167
1168 let data = read_dir(&dir).unwrap();
1169 assert_eq!(data.runs.len(), 4);
1170 assert!(data
1171 .runs
1172 .iter()
1173 .any(|run| run.id == 101 && run.conclusion.is_none()));
1174 assert!(data
1175 .runs
1176 .iter()
1177 .any(|run| run.id == 102 && run.run_started_at.is_none()));
1178 assert!(data
1179 .runs
1180 .iter()
1181 .any(|run| run.id == 103 && run.conclusion.is_none() && run.run_started_at.is_none()));
1182
1183 let by_id = |id: u64| data.jobs.iter().find(|job| job.id == id).unwrap();
1184 for id in [1, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] {
1185 assert!(by_id(id).counts(), "job {id} should count");
1186 }
1187 for id in [2, 3, 14] {
1188 assert!(!by_id(id).counts(), "job {id} should be in progress");
1189 assert!(by_id(id).completed_at.is_none(), "job {id}");
1190 }
1191 assert_eq!(
1192 data.in_progress_jobs()
1193 .map(|job| job.id)
1194 .collect::<Vec<_>>(),
1195 vec![3, 2, 14]
1196 );
1197 assert!(by_id(4).conclusion.is_none());
1198 assert!(by_id(5).conclusion.is_none());
1199 assert!(by_id(6).runner_name.is_none());
1200 assert!(by_id(7).runner_name.is_none());
1201 assert!(by_id(8).started_at.is_none());
1202 assert!(by_id(9).started_at.is_none());
1203 assert!(by_id(10).labels.is_empty());
1204 assert!(by_id(11).labels.is_empty());
1205 assert!(by_id(12).labels.is_empty());
1206 assert!(by_id(13).completed_at.is_some());
1207 assert_eq!(by_id(1).labels, vec!["ubuntu-latest".to_string()]);
1208 }
1209
1210 #[test]
1211 fn each_required_run_field_must_be_present_and_typed() {
1212 for key in ["id", "path", "run_attempt", "run_number", "event"] {
1213 let dir = scratch(&format!("run-missing-{key}"));
1214 let mut run = run_value(1, ".github/workflows/ci.yml");
1215 run.as_object_mut().unwrap().remove(key);
1216 write_json(&dir, "runs-1.json", &runs_body(vec![run]));
1217 let err = read_dir(&dir).unwrap_err();
1218 assert_eq!(err.to_string(), "invalid JSON", "{key}");
1219 assert_eq!(err.path, dir.join("runs-1.json"));
1220 }
1221
1222 let bad = [
1223 ("id", serde_json::json!("1")),
1224 ("id", Value::Null),
1225 ("id", serde_json::json!(-1)),
1226 ("id", serde_json::json!(1.5)),
1227 ("id", Value::Bool(true)),
1228 ("path", serde_json::json!(1)),
1229 ("path", Value::Null),
1230 ("run_attempt", serde_json::json!("1")),
1231 ("run_number", serde_json::json!([])),
1232 ("event", Value::Bool(false)),
1233 ("conclusion", serde_json::json!(1)),
1234 ("run_started_at", serde_json::json!(true)),
1235 ("head_branch", serde_json::json!(1)),
1236 ("head_branch", Value::Bool(true)),
1237 ("pull_requests", serde_json::json!("12")),
1238 ("pull_requests", serde_json::json!([1])),
1239 ("pull_requests", serde_json::json!([{"number": "4"}])),
1240 ("pull_requests", serde_json::json!([{"number": 1.5}])),
1241 ("pull_requests", serde_json::json!([{"number": -1}])),
1242 ];
1243 for (index, (key, replacement)) in bad.iter().enumerate() {
1244 let dir = scratch(&format!("run-type-{index}"));
1245 let mut run = run_value(1, ".github/workflows/ci.yml");
1246 run[key] = replacement.clone();
1247 write_json(&dir, "runs-4.json", &runs_body(vec![run]));
1248 let err = read_dir(&dir).unwrap_err();
1249 assert_eq!(err.to_string(), "invalid JSON", "{key}");
1250 assert_eq!(err.path, dir.join("runs-4.json"));
1251 }
1252 }
1253
1254 #[test]
1255 fn head_branch_and_pull_requests_are_optional_and_order_independent() {
1256 let dir = scratch("branch-pr");
1257 let crlf = "{\r\n\"workflow_runs\": [\r\n{\r\n\"pull_requests\": [{\"id\": 9, \"number\": 12}, null, {\"url\": \"https://example.invalid\"}, {\"number\": 4}],\r\n\"head_branch\": \" Feature \",\r\n\"run_started_at\": \"2026-04-01T00:00:00Z\",\r\n\"conclusion\": \"SUCCESS\",\r\n\"event\": \"PULL_REQUEST\",\r\n\"run_number\": 2,\r\n\"run_attempt\": 1,\r\n\"path\": \"ACME/APP/.github/workflows/CI.yml@MAIN\",\r\n\"id\": 7\r\n},\r\n{\r\n\"id\": 8,\r\n\"path\": \".github/workflows/ci.yml\",\r\n\"run_attempt\": 1,\r\n\"run_number\": 3,\r\n\"event\": \"push\",\r\n\"conclusion\": null,\r\n\"head_branch\": \"\",\r\n\"pull_requests\": []\r\n},\r\n{\r\n\"id\": 9,\r\n\"path\": \".github/workflows/ci.yml\",\r\n\"run_attempt\": 1,\r\n\"run_number\": 4,\r\n\"event\": \"push\",\r\n\"pull_requests\": null\r\n}\r\n]\r\n}\r\n";
1258 fs::write(dir.join("runs-1.json"), crlf).unwrap();
1259 let data = read_dir(&dir).unwrap();
1260 let by_id = |id: u64| data.runs.iter().find(|run| run.id == id).unwrap();
1261 assert_eq!(by_id(7).head_branch.as_deref(), Some(" Feature "));
1262 assert_eq!(by_id(7).pull_requests, vec![12, 4]);
1263 assert_eq!(by_id(7).conclusion.as_deref(), Some("SUCCESS"));
1264 assert_eq!(by_id(7).path, ".github/workflows/CI.yml");
1265 assert_eq!(by_id(8).head_branch.as_deref(), Some(""));
1266 assert!(by_id(8).pull_requests.is_empty());
1267 assert!(by_id(8).conclusion.is_none());
1268 assert!(by_id(9).head_branch.is_none());
1269 assert!(by_id(9).pull_requests.is_empty());
1270 }
1271
1272 #[test]
1273 fn each_required_job_field_must_be_present_and_typed() {
1274 for key in ["id", "run_id", "run_attempt", "name"] {
1275 let dir = scratch(&format!("job-missing-{key}"));
1276 let mut job = job_value(10, 4, "build");
1277 job.as_object_mut().unwrap().remove(key);
1278 write_json(&dir, "jobs-4.json", &jobs_body(vec![job]));
1279 let err = read_dir(&dir).unwrap_err();
1280 assert_eq!(err.to_string(), "invalid JSON", "{key}");
1281 assert_eq!(err.path, dir.join("jobs-4.json"));
1282 }
1283
1284 let bad = [
1285 ("id", serde_json::json!("10")),
1286 ("run_id", serde_json::json!(1.5)),
1287 ("run_attempt", Value::Null),
1288 ("name", serde_json::json!(10)),
1289 ("labels", serde_json::json!("ubuntu-latest")),
1290 ("labels", serde_json::json!([1])),
1291 ("started_at", serde_json::json!(0)),
1292 ("completed_at", serde_json::json!(60)),
1293 ("conclusion", serde_json::json!(["success"])),
1294 ("runner_name", serde_json::json!(8)),
1295 ];
1296 for (index, (key, replacement)) in bad.iter().enumerate() {
1297 let dir = scratch(&format!("job-type-{index}"));
1298 let mut job = job_value(10, 4, "build");
1299 job[key] = replacement.clone();
1300 write_json(&dir, "jobs-4-2.json", &jobs_body(vec![job]));
1301 let err = read_dir(&dir).unwrap_err();
1302 assert_eq!(err.to_string(), "invalid JSON", "{key}");
1303 assert_eq!(err.path, dir.join("jobs-4-2.json"));
1304 assert!(!err.to_string().contains("ubuntu-latest"));
1305 }
1306 }
1307
1308 #[test]
1309 fn broken_json_names_the_file_and_hides_the_body() {
1310 let secret = "LEAKED_SECRET_TOKEN";
1311 let cases = [
1312 ("runs-1.json", format!("{{ {secret}")),
1313 ("runs-2.json", secret.to_string()),
1314 ("runs-3.json", format!(r#"{{"workflow_runs":"{secret}"}}"#)),
1315 ("runs-4.json", "[]".to_string()),
1316 ("runs-5.json", "null".to_string()),
1317 ("runs-6.json", String::new()),
1318 (
1319 "runs-7.json",
1320 format!(r#"{{"workflow_runs":[1,"{secret}"]}}"#),
1321 ),
1322 (
1323 "jobs-9.json",
1324 format!(r#"{{"jobs":[{{"name":"{secret}"}}]}}"#),
1325 ),
1326 ("jobs-9-2.json", "{".to_string()),
1327 ("jobs-8.json", "null".to_string()),
1328 ];
1329 for (index, (name, body)) in cases.iter().enumerate() {
1330 let dir = scratch(&format!("broken-{index}"));
1331 fs::write(dir.join(name), body).unwrap();
1332 let err = read_dir(&dir).unwrap_err();
1333 assert_eq!(err.to_string(), "invalid JSON", "{name}");
1334 assert_eq!(err.path, dir.join(name));
1335 assert!(!err.to_string().contains(secret), "{name}");
1336 }
1337
1338 let dir = scratch("second-page-bad");
1339 write_json(
1340 &dir,
1341 "runs-1.json",
1342 &runs_body(vec![run_value(1, ".github/workflows/ci.yml")]),
1343 );
1344 fs::write(
1345 dir.join("runs-2.json"),
1346 format!(r#"{{"workflow_runs":"{secret}"}}"#),
1347 )
1348 .unwrap();
1349 let err = read_dir(&dir).unwrap_err();
1350 assert_eq!(err.path, dir.join("runs-2.json"));
1351 assert_eq!(err.to_string(), "invalid JSON");
1352 assert!(!err.to_string().contains(secret));
1353 }
1354
1355 #[test]
1356 fn duplicate_ids_name_the_later_file() {
1357 let dir = scratch("duplicate-runs");
1358 write_json(
1359 &dir,
1360 "runs-1.json",
1361 &runs_body(vec![run_value(5, ".github/workflows/a.yml")]),
1362 );
1363 write_json(
1364 &dir,
1365 "runs-2.json",
1366 &runs_body(vec![run_value(5, ".github/workflows/b.yml")]),
1367 );
1368 let err = read_dir(&dir).unwrap_err();
1369 assert_eq!(err.to_string(), "duplicate id");
1370 assert_eq!(err.path, dir.join("runs-2.json"));
1371
1372 let dir = scratch("duplicate-in-file");
1373 write_json(
1374 &dir,
1375 "runs-1.json",
1376 &runs_body(vec![
1377 run_value(5, ".github/workflows/a.yml"),
1378 run_value(5, ".github/workflows/b.yml"),
1379 ]),
1380 );
1381 let err = read_dir(&dir).unwrap_err();
1382 assert_eq!(err.to_string(), "duplicate id");
1383 assert_eq!(err.path, dir.join("runs-1.json"));
1384 }
1385
1386 #[test]
1387 fn job_run_id_must_match_the_file_name() {
1388 let dir = scratch("mismatch");
1389 write_json(
1390 &dir,
1391 "jobs-15.json",
1392 &jobs_body(vec![job_value(1, 16, "build")]),
1393 );
1394 let err = read_dir(&dir).unwrap_err();
1395 assert_eq!(err.to_string(), "run id does not match file name");
1396 assert_eq!(err.path, dir.join("jobs-15.json"));
1397 assert!(!err.to_string().contains("build"));
1398
1399 let dir = scratch("mismatch-150");
1400 write_json(
1401 &dir,
1402 "jobs-150.json",
1403 &jobs_body(vec![job_value(1, 15, "build")]),
1404 );
1405 let err = read_dir(&dir).unwrap_err();
1406 assert_eq!(err.to_string(), "run id does not match file name");
1407 assert_eq!(err.path, dir.join("jobs-150.json"));
1408 }
1409
1410 #[test]
1411 fn utf8_bom_is_accepted() {
1412 let dir = scratch("bom");
1413 let mut bytes = vec![0xEF, 0xBB, 0xBF];
1414 bytes.extend(br#"{"total_count":0,"workflow_runs":[]}"#);
1415 fs::write(dir.join("runs-1.json"), bytes).unwrap();
1416 let data = read_dir(&dir).unwrap();
1417 assert!(data.runs.is_empty());
1418 assert!(data.jobs.is_empty());
1419 }
1420
1421 #[test]
1422 fn invalid_utf8_is_invalid_json() {
1423 let dir = scratch("utf8");
1424 fs::write(dir.join("jobs-3.json"), [0xFF, 0xFE, b'{']).unwrap();
1425 let err = read_dir(&dir).unwrap_err();
1426 assert_eq!(err.to_string(), "invalid JSON");
1427 assert_eq!(err.path, dir.join("jobs-3.json"));
1428 }
1429
1430 #[test]
1431 fn missing_directory_and_file_path_fail() {
1432 let missing = scratch("missing-parent").join("nope");
1433 let err = read_dir(&missing).unwrap_err();
1434 assert_eq!(err.to_string(), "cannot read directory");
1435 assert_eq!(err.path, missing);
1436
1437 let dir = scratch("file-not-dir");
1438 let file = dir.join("runs-1.json");
1439 fs::write(&file, b"{}").unwrap();
1440 let err = read_dir(&file).unwrap_err();
1441 assert_eq!(err.to_string(), "cannot read directory");
1442 assert_eq!(err.path, file);
1443 }
1444
1445 #[test]
1446 fn directory_named_like_a_page_cannot_be_read() {
1447 let dir = scratch("dir-page");
1448 fs::create_dir(dir.join("runs-1.json")).unwrap();
1449 let err = read_dir(&dir).unwrap_err();
1450 assert_eq!(err.to_string(), "cannot read file");
1451 assert_eq!(err.path, dir.join("runs-1.json"));
1452 }
1453
1454 #[test]
1455 fn empty_directory_has_no_runs_or_jobs() {
1456 let dir = scratch("empty");
1457 let data = read_dir(&dir).unwrap();
1458 assert!(data.runs.is_empty());
1459 assert!(data.jobs.is_empty());
1460 assert_eq!(data.counted_jobs().count(), 0);
1461 assert_eq!(data.in_progress_jobs().count(), 0);
1462 }
1463
1464 #[test]
1465 fn non_object_list_items_are_invalid_json() {
1466 let dir = scratch("bad-items");
1467 fs::write(dir.join("runs-1.json"), br#"{"workflow_runs":[null]}"#).unwrap();
1468 let err = read_dir(&dir).unwrap_err();
1469 assert_eq!(err.to_string(), "invalid JSON");
1470 assert_eq!(err.path, dir.join("runs-1.json"));
1471
1472 fs::write(dir.join("jobs-1.json"), br#"{"jobs":[{}]}"#).unwrap();
1473 fs::remove_file(dir.join("runs-1.json")).unwrap();
1474 let err = read_dir(&dir).unwrap_err();
1475 assert_eq!(err.to_string(), "invalid JSON");
1476 assert_eq!(err.path, dir.join("jobs-1.json"));
1477 }
1478
1479 fn assert_links(raw: &str, stored: &str, csv_path: &str, matches: bool) {
1480 let linked = linked_workflow_path(raw);
1481 assert_eq!(linked, stored, "{raw}");
1482 assert_eq!(linked_workflow_path(&linked), linked, "{raw}");
1483 assert_eq!(workflow_paths_match(raw, csv_path), matches, "{raw}");
1484 assert_eq!(workflow_paths_match(&linked, csv_path), matches, "{raw}");
1485 }
1486
1487 #[test]
1488 fn run_paths_link_to_csv_workflow_paths() {
1489 let cases = [
1490 (
1491 ".github/workflows/build.yml@main",
1492 ".github/workflows/build.yml",
1493 ".github/workflows/build.yml",
1494 true,
1495 ),
1496 (
1497 ".github/workflows/ci.yml@refs/heads/main",
1498 ".github/workflows/ci.yml",
1499 ".github/workflows/ci.yml",
1500 true,
1501 ),
1502 (
1503 ".github/workflows/ci.yml@refs/heads/feature/foo",
1504 ".github/workflows/ci.yml",
1505 ".github/workflows/ci.yml",
1506 true,
1507 ),
1508 (
1509 ".github/workflows/ci.yml@REFS/HEADS/Feature/Foo-1",
1510 ".github/workflows/ci.yml",
1511 ".github/workflows/ci.yml",
1512 true,
1513 ),
1514 (
1515 "octocat/octo-repo/.github/workflows/ci.yml@main",
1516 ".github/workflows/ci.yml",
1517 ".github/workflows/ci.yml",
1518 true,
1519 ),
1520 (
1521 "octocat/octo-repo/.github/workflows/ci.yml@refs/heads/main",
1522 ".github/workflows/ci.yml",
1523 ".github/workflows/ci.yml",
1524 true,
1525 ),
1526 (
1527 "OWNER/REPO/.GitHub/Workflows/CI.yml@MAIN",
1528 ".GitHub/Workflows/CI.yml",
1529 ".github/workflows/ci.yml",
1530 true,
1531 ),
1532 (
1533 "Owner/Repo/.github/workflows/ci.yml@Refs/Heads/Main",
1534 ".github/workflows/ci.yml",
1535 ".GITHUB/WORKFLOWS/CI.YML",
1536 true,
1537 ),
1538 (
1539 "acme/widgets/.github/workflows/release.yaml@refs/tags/v1.2.3",
1540 ".github/workflows/release.yaml",
1541 ".github/workflows/release.yaml",
1542 true,
1543 ),
1544 (
1545 "acme/widgets/.github/workflows/ci.yml@refs/pull/3/merge",
1546 ".github/workflows/ci.yml",
1547 ".github/workflows/ci.yml",
1548 true,
1549 ),
1550 (
1551 ".github/workflows/ci.yml@0123456789abcdef",
1552 ".github/workflows/ci.yml",
1553 ".github/workflows/ci.yml",
1554 true,
1555 ),
1556 (
1557 " .github/workflows/ci.yml@main ",
1558 ".github/workflows/ci.yml",
1559 ".github/workflows/ci.yml",
1560 true,
1561 ),
1562 (
1563 ".github/workflows/ci.yml@main\r",
1564 ".github/workflows/ci.yml",
1565 ".github/workflows/ci.yml",
1566 true,
1567 ),
1568 (
1569 "\u{feff}.github/workflows/ci.yml@main",
1570 ".github/workflows/ci.yml",
1571 ".github/workflows/ci.yml",
1572 true,
1573 ),
1574 (
1575 "owner\\repo\\.github\\workflows\\ci.yml@main",
1576 ".github/workflows/ci.yml",
1577 ".github/workflows/ci.yml",
1578 true,
1579 ),
1580 (
1581 ".github/workflows/ci.yml",
1582 ".github/workflows/ci.yml",
1583 ".github/workflows/ci.yml",
1584 true,
1585 ),
1586 (
1587 "owner/repo/.github/workflows/ci.yml",
1588 ".github/workflows/ci.yml",
1589 ".github/workflows/ci.yml",
1590 true,
1591 ),
1592 (
1593 "my.org/repo.name/.github/workflows/ci.yml@refs/heads/main",
1594 ".github/workflows/ci.yml",
1595 ".github/workflows/ci.yml",
1596 true,
1597 ),
1598 (
1599 "owner/repo/custom.yml@main",
1600 "owner/repo/custom.yml",
1601 ".github/workflows/custom.yml",
1602 false,
1603 ),
1604 (
1605 "extra/owner/repo/.github/workflows/ci.yml@main",
1606 "extra/owner/repo/.github/workflows/ci.yml",
1607 ".github/workflows/ci.yml",
1608 false,
1609 ),
1610 (
1611 "only-repo/.github/workflows/ci.yml@main",
1612 "only-repo/.github/workflows/ci.yml",
1613 ".github/workflows/ci.yml",
1614 false,
1615 ),
1616 (
1617 "owner/repo/.github/workflow/ci.yml@main",
1618 "owner/repo/.github/workflow/ci.yml",
1619 ".github/workflows/ci.yml",
1620 false,
1621 ),
1622 ("@main", "", ".github/workflows/ci.yml", false),
1623 ("", "", ".github/workflows/ci.yml", false),
1624 (
1625 ".github/workflows/ci.yml@main",
1626 ".github/workflows/ci.yml",
1627 "",
1628 false,
1629 ),
1630 ];
1631 for (raw, stored, csv_path, matches) in cases {
1632 assert_links(raw, stored, csv_path, matches);
1633 }
1634 }
1635
1636 #[test]
1637 fn read_dir_links_main_refs_heads_and_owner_repo_prefixes() {
1638 let dir = scratch("link-forms");
1639 write_json(
1640 &dir,
1641 "runs-1.json",
1642 &runs_body(vec![
1643 run_value(1, ".github/workflows/build.yml@main"),
1644 run_value(2, ".github/workflows/ci.yml@refs/heads/main"),
1645 run_value(3, "octocat/octo-repo/.github/workflows/lint.yml@main"),
1646 run_value(
1647 4,
1648 "octocat/octo-repo/.github/workflows/release.yml@refs/heads/feature/foo",
1649 ),
1650 ]),
1651 );
1652 let data = read_dir(&dir).unwrap();
1653 let path = |id: u64| {
1654 data.runs
1655 .iter()
1656 .find(|run| run.id == id)
1657 .unwrap()
1658 .path
1659 .clone()
1660 };
1661 assert_eq!(path(1), ".github/workflows/build.yml");
1662 assert_eq!(path(2), ".github/workflows/ci.yml");
1663 assert_eq!(path(3), ".github/workflows/lint.yml");
1664 assert_eq!(path(4), ".github/workflows/release.yml");
1665 let repository = |id: u64| {
1666 data.runs
1667 .iter()
1668 .find(|run| run.id == id)
1669 .unwrap()
1670 .repository
1671 .clone()
1672 };
1673 assert_eq!(repository(1), None);
1674 assert_eq!(repository(2), None);
1675 assert_eq!(repository(3).as_deref(), Some("octocat/octo-repo"));
1676 assert_eq!(repository(4).as_deref(), Some("octocat/octo-repo"));
1677 assert!(data
1678 .runs
1679 .iter()
1680 .any(|run| run.id == 1 && run.links_to(".github/workflows/build.yml")));
1681 assert!(data
1682 .runs
1683 .iter()
1684 .any(|run| { run.id == 2 && run.links_to(".github/workflows/ci.yml") }));
1685 assert!(data
1686 .runs
1687 .iter()
1688 .any(|run| { run.id == 3 && run.links_to(".GitHub/Workflows/Lint.yml") }));
1689 assert!(data
1690 .runs
1691 .iter()
1692 .any(|run| { run.id == 4 && run.links_to(".github/workflows/release.yml") }));
1693 assert!(!data.runs.iter().any(|run| run.path.contains('@')));
1694 assert!(!data.runs.iter().any(|run| run.path.contains("octocat")));
1695 assert_eq!(data.workflow_path(2), ".github/workflows/ci.yml");
1696 assert_eq!(data.workflow_path(3), ".github/workflows/lint.yml");
1697 }
1698
1699 #[test]
1700 fn linked_paths_accept_crlf_reordered_keys_empty_fields_and_case() {
1701 let dir = scratch("link-variants");
1702 let crlf = "{\r\n\"total_count\": 2,\r\n\"workflow_runs\": [\r\n{\r\n\"run_number\": 8,\r\n\"event\": \"push\",\r\n\"conclusion\": null,\r\n\"id\": 8,\r\n\"run_started_at\": \"2026-04-01T00:00:00Z\",\r\n\"run_attempt\": 1,\r\n\"path\": \"OWNER/REPO/.GitHub/Workflows/CI.yml@MAIN\"\r\n},\r\n{\r\n\"path\": \"\",\r\n\"id\": 9,\r\n\"run_attempt\": 2,\r\n\"run_number\": 9,\r\n\"event\": \"pull_request\"\r\n}\r\n]\r\n}\r\n";
1703 fs::write(dir.join("runs-1.json"), crlf).unwrap();
1704 let reversed = r#"{"workflow_runs":[{"run_started_at":"2026-05-01T01:02:03Z","conclusion":"success","event":"workflow_dispatch","run_number":10,"run_attempt":3,"path":".github/workflows/docs.yml@refs/heads/main","id":10,"html_url":"https://example.invalid/hidden"}]}"#;
1705 fs::write(dir.join("runs-2.json"), reversed).unwrap();
1706
1707 let data = read_dir(&dir).unwrap();
1708 let by_id = |id: u64| data.runs.iter().find(|run| run.id == id).unwrap();
1709 assert_eq!(by_id(8).path, ".GitHub/Workflows/CI.yml");
1710 assert_eq!(by_id(8).repository.as_deref(), Some("OWNER/REPO"));
1711 assert!(by_id(8).links_to(".github/workflows/ci.yml"));
1712 assert!(by_id(8).conclusion.is_none());
1713 assert_eq!(by_id(8).run_attempt, 1);
1714 assert_eq!(by_id(9).path, "");
1715 assert!(!by_id(9).links_to(".github/workflows/ci.yml"));
1716 assert!(by_id(9).conclusion.is_none());
1717 assert!(by_id(9).run_started_at.is_none());
1718 assert_eq!(by_id(10).path, ".github/workflows/docs.yml");
1719 assert!(by_id(10).links_to(".github/workflows/docs.yml"));
1720 assert_eq!(by_id(10).event, "workflow_dispatch");
1721 assert!(!format!("{data:?}").contains("example.invalid"));
1722 }
1723
1724 #[test]
1725 fn duplicate_job_id_counts_once() {
1726 let dir = scratch("duplicate-jobs-order");
1727 write_json(
1728 &dir,
1729 "jobs-7-b.json",
1730 &jobs_body(vec![job_value(70, 7, "from-later-file")]),
1731 );
1732 write_json(
1733 &dir,
1734 "jobs-7-a.json",
1735 &jobs_body(vec![job_value(70, 7, "from-earlier-file")]),
1736 );
1737 write_json(
1738 &dir,
1739 "jobs-7.json",
1740 &jobs_body(vec![
1741 job_value(71, 7, "first-in-array"),
1742 job_value(71, 7, "second-in-array"),
1743 ]),
1744 );
1745 let data = read_dir(&dir).unwrap();
1746 assert_eq!(data.jobs.iter().filter(|job| job.id == 70).count(), 1);
1747 assert_eq!(data.jobs.iter().filter(|job| job.id == 71).count(), 1);
1748 assert_eq!(
1749 data.jobs.iter().find(|job| job.id == 70).unwrap().name,
1750 "from-earlier-file"
1751 );
1752 assert_eq!(
1753 data.jobs.iter().find(|job| job.id == 71).unwrap().name,
1754 "first-in-array"
1755 );
1756 assert_eq!(data.jobs.len(), 2);
1757
1758 let dir = scratch("duplicate-jobs-fields");
1759 let mut first = job_value(70, 7, "kept");
1760 first.as_object_mut().unwrap().remove("started_at");
1761 first["labels"] = serde_json::json!(["ubuntu-latest"]);
1762 let mut second = job_value(70, 7, "dropped");
1763 second["labels"] = serde_json::json!(["windows-latest"]);
1764 second["started_at"] = serde_json::json!("2026-04-01T00:00:00Z");
1765 let crlf_first = serde_json::to_string(&jobs_body(vec![first]))
1766 .unwrap()
1767 .replace('\n', "\r\n");
1768 fs::write(dir.join("jobs-7-1.json"), crlf_first).unwrap();
1769 let reversed = format!(
1770 "{{\"jobs\":[{}]}}",
1771 r#"{"runner_name":"other","conclusion":"failure","completed_at":"2026-04-01T00:02:00Z","labels":["windows-latest"],"started_at":"2026-04-01T00:00:00Z","name":"dropped","run_attempt":1,"run_id":7,"id":70}"#
1772 );
1773 fs::write(dir.join("jobs-7-2.json"), reversed).unwrap();
1774 let data = read_dir(&dir).unwrap();
1775 assert_eq!(data.jobs.len(), 1);
1776 let kept = &data.jobs[0];
1777 assert_eq!(kept.name, "kept");
1778 assert!(kept.started_at.is_none());
1779 assert_eq!(kept.labels, vec!["ubuntu-latest".to_string()]);
1780 assert_eq!(data.billable_jobs().count(), 1);
1781
1782 let dir = scratch("duplicate-still-checks-run-id");
1783 write_json(
1784 &dir,
1785 "jobs-15.json",
1786 &jobs_body(vec![job_value(5, 15, "build")]),
1787 );
1788 write_json(
1789 &dir,
1790 "jobs-16.json",
1791 &jobs_body(vec![job_value(5, 99, "other")]),
1792 );
1793 let err = read_dir(&dir).unwrap_err();
1794 assert_eq!(err.to_string(), "run id does not match file name");
1795 assert_eq!(err.path, dir.join("jobs-16.json"));
1796 }
1797
1798 #[test]
1799 fn attempt_endpoint_files_are_not_mixed_with_filter_all() {
1800 let dir = scratch("attempt-files");
1801 write_json(
1802 &dir,
1803 "jobs-9.json",
1804 &jobs_body(vec![job_value(1, 9, "filter-all")]),
1805 );
1806 write_json(
1807 &dir,
1808 "jobs-9-2.json",
1809 &jobs_body(vec![job_value(2, 9, "page-two")]),
1810 );
1811 fs::write(dir.join("jobs-9-attempts-1.json"), b"{").unwrap();
1812 fs::write(dir.join("jobs-9-attempt-2.json"), b"null").unwrap();
1813 fs::write(
1814 dir.join("jobs-9-ATTEMPTS-3.json"),
1815 br#"{"jobs":[{"id":3}]}"#,
1816 )
1817 .unwrap();
1818 let nested = dir.join("attempts").join("1");
1819 fs::create_dir_all(&nested).unwrap();
1820 fs::write(nested.join("jobs-9.json"), b"{").unwrap();
1821 let upper = dir.join("Attempts").join("2");
1822 fs::create_dir_all(&upper).unwrap();
1823 fs::write(
1824 upper.join("jobs-9.json"),
1825 serde_json::to_vec(&jobs_body(vec![job_value(4, 9, "attempt-only")])).unwrap(),
1826 )
1827 .unwrap();
1828
1829 let data = read_dir(&dir).unwrap();
1830 let ids: Vec<u64> = data.jobs.iter().map(|job| job.id).collect();
1831 assert_eq!(ids, vec![1, 2]);
1832 assert_eq!(data.jobs[0].name, "filter-all");
1833 assert_eq!(data.jobs[1].name, "page-two");
1834 assert!(!is_filter_all_jobs_name("jobs-9-attempts-1.json"));
1835 assert!(!is_filter_all_jobs_name("jobs-9-attempt-2.json"));
1836 assert!(!is_filter_all_jobs_name("jobs-9-ATTEMPTS-3.json"));
1837 assert!(is_filter_all_jobs_name("jobs-9.json"));
1838 assert!(is_filter_all_jobs_name("jobs-9-2.json"));
1839 assert!(is_filter_all_jobs_name("jobs-15_page3.json"));
1840 }
1841
1842 fn stamp_job(
1843 id: u64,
1844 run_id: u64,
1845 attempt: u64,
1846 name: &str,
1847 started: Option<&str>,
1848 completed: Option<&str>,
1849 ) -> Value {
1850 let mut job = job_value(id, run_id, name);
1851 job["run_attempt"] = serde_json::json!(attempt);
1852 match started {
1853 Some(text) => job["started_at"] = serde_json::json!(text),
1854 None => {
1855 job.as_object_mut().unwrap().remove("started_at");
1856 }
1857 }
1858 match completed {
1859 Some(text) => job["completed_at"] = serde_json::json!(text),
1860 None => job["completed_at"] = Value::Null,
1861 }
1862 job
1863 }
1864
1865 fn run_with_start(id: u64, path: &str, attempt: u64, started: Option<&str>) -> Value {
1866 let mut run = run_value(id, path);
1867 run["run_attempt"] = serde_json::json!(attempt);
1868 match started {
1869 Some(text) => run["run_started_at"] = serde_json::json!(text),
1870 None => {
1871 run.as_object_mut().unwrap().remove("run_started_at");
1872 }
1873 }
1874 run
1875 }
1876
1877 #[test]
1878 fn inherited_copy_on_the_last_attempt_costs_zero() {
1879 let dir = scratch("copy-main");
1880 write_json(
1881 &dir,
1882 "runs-1.json",
1883 &runs_body(vec![run_with_start(
1884 9,
1885 ".github/workflows/ci.yml@refs/heads/main",
1886 2,
1887 Some("2026-04-01T12:00:00Z"),
1888 )]),
1889 );
1890 write_json(
1891 &dir,
1892 "jobs-9.json",
1893 &jobs_body(vec![
1894 stamp_job(
1895 1,
1896 9,
1897 1,
1898 "test",
1899 Some("2026-04-01T10:00:00Z"),
1900 Some("2026-04-01T10:10:00Z"),
1901 ),
1902 stamp_job(
1903 2,
1904 9,
1905 2,
1906 "test",
1907 Some("2026-04-01T10:00:00Z"),
1908 Some("2026-04-01T10:10:00Z"),
1909 ),
1910 stamp_job(
1911 3,
1912 9,
1913 2,
1914 "lint",
1915 Some("2026-04-01T12:00:30Z"),
1916 Some("2026-04-01T12:02:00Z"),
1917 ),
1918 stamp_job(
1919 4,
1920 9,
1921 2,
1922 "pack",
1923 Some("2026-04-01T12:00:00Z"),
1924 Some("2026-04-01T12:01:00Z"),
1925 ),
1926 stamp_job(5, 9, 2, "queue", Some("2026-04-01T11:00:00Z"), None),
1927 ]),
1928 );
1929 let data = read_dir(&dir).unwrap();
1930 assert!(data.runs[0].links_to(".github/workflows/ci.yml"));
1931 let copy = data.jobs.iter().find(|job| job.id == 2).unwrap();
1932 assert!(data.is_inherited_copy(copy));
1933 assert_eq!(
1934 data.inherited_copy_net(copy).unwrap().to_decimal_string(),
1935 "0.000000"
1936 );
1937 assert_eq!(
1938 data.inherited_copy_net(copy).unwrap().format_cents(),
1939 "0.00"
1940 );
1941 assert!(copy.counts());
1942 let queued = data.jobs.iter().find(|job| job.id == 5).unwrap();
1943 assert!(data.is_inherited_copy(queued));
1944 assert!(!queued.counts());
1945 assert_eq!(
1946 data.inherited_copy_net(queued).unwrap().to_decimal_string(),
1947 "0.000000"
1948 );
1949 for id in [1, 3, 4] {
1950 let job = data.jobs.iter().find(|job| job.id == id).unwrap();
1951 assert!(!data.is_inherited_copy(job), "{id}");
1952 assert!(data.inherited_copy_net(job).is_none(), "{id}");
1953 }
1954 let billable: Vec<u64> = data.billable_jobs().map(|job| job.id).collect();
1955 assert_eq!(billable, vec![3, 4, 1]);
1956 assert_eq!(data.jobs.iter().filter(|job| job.id == 2).count(), 1);
1957
1958 let cases = [
1959 ("absent-started", None, Some("2026-04-01T12:00:00Z"), false),
1960 (
1961 "empty-started",
1962 Some(""),
1963 Some("2026-04-01T12:00:00Z"),
1964 false,
1965 ),
1966 (
1967 "blank-started",
1968 Some(" "),
1969 Some("2026-04-01T12:00:00Z"),
1970 false,
1971 ),
1972 (
1973 "bad-started",
1974 Some("not-a-time"),
1975 Some("2026-04-01T12:00:00Z"),
1976 false,
1977 ),
1978 (
1979 "equal-fraction",
1980 Some("2026-04-01T12:00:00.000Z"),
1981 Some("2026-04-01T12:00:00Z"),
1982 false,
1983 ),
1984 (
1985 "equal-offset",
1986 Some("2026-04-01T14:00:00+02:00"),
1987 Some("2026-04-01T12:00:00Z"),
1988 false,
1989 ),
1990 (
1991 "earlier-offset",
1992 Some("2026-04-01T13:59:59+02:00"),
1993 Some("2026-04-01T12:00:00Z"),
1994 true,
1995 ),
1996 (
1997 "earlier-fraction",
1998 Some("2026-04-01T11:59:59.999Z"),
1999 Some("2026-04-01T12:00:00.000Z"),
2000 true,
2001 ),
2002 ];
2003 for (name, started, run_started, is_copy) in cases {
2004 let dir = scratch(&format!("copy-{name}"));
2005 write_json(
2006 &dir,
2007 "runs-1.json",
2008 &runs_body(vec![run_with_start(
2009 1,
2010 "OWNER/REPO/.github/workflows/ci.yml@REFS/HEADS/Main",
2011 2,
2012 run_started,
2013 )]),
2014 );
2015 let job = stamp_job(20, 1, 2, "test", started, Some("2026-04-01T12:30:00Z"));
2016 write_json(&dir, "jobs-1.json", &jobs_body(vec![job]));
2017 let data = read_dir(&dir).unwrap();
2018 assert!(data.runs[0].links_to(".github/workflows/ci.yml"), "{name}");
2019 let job = &data.jobs[0];
2020 assert_eq!(data.is_inherited_copy(job), is_copy, "{name}");
2021 if is_copy {
2022 assert_eq!(data.billable_jobs().count(), 0, "{name}");
2023 assert_eq!(
2024 data.inherited_copy_net(job).unwrap().to_decimal_string(),
2025 "0.000000",
2026 "{name}"
2027 );
2028 } else {
2029 assert_eq!(data.billable_jobs().count(), 1, "{name}");
2030 assert!(data.inherited_copy_net(job).is_none(), "{name}");
2031 }
2032 }
2033
2034 let dir = scratch("copy-null-started");
2035 write_json(
2036 &dir,
2037 "runs-1.json",
2038 &runs_body(vec![run_with_start(
2039 1,
2040 ".github/workflows/ci.yml@main",
2041 2,
2042 Some("2026-04-01T12:00:00Z"),
2043 )]),
2044 );
2045 let mut job = stamp_job(
2046 21,
2047 1,
2048 2,
2049 "test",
2050 Some("2026-04-01T10:00:00Z"),
2051 Some("2026-04-01T10:05:00Z"),
2052 );
2053 job["started_at"] = Value::Null;
2054 write_json(&dir, "jobs-1.json", &jobs_body(vec![job]));
2055 let data = read_dir(&dir).unwrap();
2056 assert!(!data.is_inherited_copy(&data.jobs[0]));
2057 assert_eq!(data.billable_jobs().count(), 1);
2058
2059 let dir = scratch("copy-missing-run-start");
2060 let mut run = run_with_start(1, ".github/workflows/ci.yml@main", 2, None);
2061 run["run_started_at"] = Value::Null;
2062 write_json(&dir, "runs-a.json", &runs_body(vec![run.clone()]));
2063 write_json(
2064 &dir,
2065 "jobs-1.json",
2066 &jobs_body(vec![stamp_job(
2067 22,
2068 1,
2069 2,
2070 "test",
2071 Some("2026-04-01T10:00:00Z"),
2072 Some("2026-04-01T10:05:00Z"),
2073 )]),
2074 );
2075 let data = read_dir(&dir).unwrap();
2076 assert!(!data.is_inherited_copy(&data.jobs[0]));
2077
2078 let dir = scratch("copy-empty-run-start");
2079 write_json(
2080 &dir,
2081 "runs-1.json",
2082 &runs_body(vec![run_with_start(
2083 1,
2084 ".github/workflows/ci.yml@main",
2085 2,
2086 Some(""),
2087 )]),
2088 );
2089 write_json(
2090 &dir,
2091 "jobs-1.json",
2092 &jobs_body(vec![stamp_job(
2093 23,
2094 1,
2095 2,
2096 "test",
2097 Some("2026-04-01T10:00:00Z"),
2098 Some("2026-04-01T10:05:00Z"),
2099 )]),
2100 );
2101 let data = read_dir(&dir).unwrap();
2102 assert!(!data.is_inherited_copy(&data.jobs[0]));
2103 assert_eq!(data.billable_jobs().count(), 1);
2104
2105 let dir = scratch("copy-absent-run-start");
2106 write_json(
2107 &dir,
2108 "runs-1.json",
2109 &runs_body(vec![run_with_start(
2110 1,
2111 ".github/workflows/ci.yml@main",
2112 2,
2113 None,
2114 )]),
2115 );
2116 write_json(
2117 &dir,
2118 "jobs-1.json",
2119 &jobs_body(vec![stamp_job(
2120 24,
2121 1,
2122 2,
2123 "test",
2124 Some("2026-04-01T10:00:00Z"),
2125 Some("2026-04-01T10:05:00Z"),
2126 )]),
2127 );
2128 let data = read_dir(&dir).unwrap();
2129 assert!(data.runs[0].run_started_at.is_none());
2130 assert!(!data.is_inherited_copy(&data.jobs[0]));
2131
2132 let dir = scratch("copy-other-attempt");
2133 write_json(
2134 &dir,
2135 "runs-1.json",
2136 &runs_body(vec![run_with_start(
2137 1,
2138 ".github/workflows/ci.yml@main",
2139 2,
2140 Some("2026-04-01T12:00:00Z"),
2141 )]),
2142 );
2143 write_json(
2144 &dir,
2145 "jobs-1.json",
2146 &jobs_body(vec![stamp_job(
2147 25,
2148 1,
2149 3,
2150 "test",
2151 Some("2026-04-01T10:00:00Z"),
2152 Some("2026-04-01T10:05:00Z"),
2153 )]),
2154 );
2155 let data = read_dir(&dir).unwrap();
2156 assert!(!data.is_inherited_copy(&data.jobs[0]));
2157 assert_eq!(data.billable_jobs().count(), 1);
2158
2159 let dir = scratch("copy-no-run");
2160 write_json(
2161 &dir,
2162 "jobs-4.json",
2163 &jobs_body(vec![stamp_job(
2164 26,
2165 4,
2166 1,
2167 "orphan",
2168 Some("2026-04-01T10:00:00Z"),
2169 Some("2026-04-01T10:05:00Z"),
2170 )]),
2171 );
2172 let data = read_dir(&dir).unwrap();
2173 assert!(!data.is_inherited_copy(&data.jobs[0]));
2174 assert_eq!(data.workflow_path(4), UNKNOWN_WORKFLOW);
2175 assert_eq!(data.billable_jobs().count(), 1);
2176
2177 let dir = scratch("copy-duplicate-and-crlf");
2178 write_json(
2179 &dir,
2180 "runs-1.json",
2181 &runs_body(vec![run_with_start(
2182 3,
2183 ".github/workflows/ci.yml@main",
2184 2,
2185 Some("2026-04-01T12:00:00Z"),
2186 )]),
2187 );
2188 let copy = stamp_job(
2189 30,
2190 3,
2191 2,
2192 "test",
2193 Some("2026-04-01T10:00:00Z"),
2194 Some("2026-04-01T11:00:00Z"),
2195 );
2196 let pretty = serde_json::to_vec_pretty(&jobs_body(vec![copy])).unwrap();
2197 let mut crlf = Vec::with_capacity(pretty.len() + 16);
2198 for byte in pretty {
2199 if byte == b'\n' {
2200 crlf.push(b'\r');
2201 }
2202 crlf.push(byte);
2203 }
2204 fs::write(dir.join("jobs-3-1.json"), crlf).unwrap();
2205 fs::write(
2206 dir.join("jobs-3-2.json"),
2207 format!(
2208 "{{\"total_count\":1,\"jobs\":[{}]}}",
2209 r#"{"completed_at":"2026-04-01T12:06:00Z","started_at":"2026-04-01T12:05:00Z","name":"renamed","labels":[],"conclusion":"success","runner_name":"runner-a","run_attempt":2,"run_id":3,"id":30}"#
2210 ),
2211 )
2212 .unwrap();
2213 let data = read_dir(&dir).unwrap();
2214 assert_eq!(data.jobs.len(), 1);
2215 assert_eq!(data.jobs[0].name, "test");
2216 assert!(data.is_inherited_copy(&data.jobs[0]));
2217 assert_eq!(data.billable_jobs().count(), 0);
2218 assert_eq!(
2219 data.inherited_copy_net(&data.jobs[0])
2220 .unwrap()
2221 .to_decimal_string(),
2222 "0.000000"
2223 );
2224 }
2225
2226 #[test]
2227 fn github_timestamps_order_by_utc_instant() {
2228 assert_eq!(parse_github_time("1970-01-01T00:00:00Z"), Some(0));
2229 assert_eq!(
2230 parse_github_time("1970-01-01T00:00:01Z"),
2231 Some(1_000_000_000)
2232 );
2233 assert_eq!(
2234 parse_github_time("2026-03-01T10:00:00Z"),
2235 parse_github_time("2026-03-01T10:00:00.000Z")
2236 );
2237 assert_eq!(
2238 parse_github_time("2026-03-01T10:00:00Z"),
2239 parse_github_time("2026-03-01T12:00:00+02:00")
2240 );
2241 assert_eq!(
2242 parse_github_time("2026-03-01T10:00:00Z"),
2243 parse_github_time("2026-03-01T10:00:00+0000")
2244 );
2245 assert_eq!(
2246 parse_github_time("2026-03-01T10:00:00Z"),
2247 parse_github_time("2026-03-01T11:00:00+01")
2248 );
2249 assert!(
2250 parse_github_time("2026-03-01T09:59:59Z") < parse_github_time("2026-03-01T10:00:00Z")
2251 );
2252 assert!(parse_github_time("2024-02-29T00:00:00Z").is_some());
2253 for bad in [
2254 "",
2255 " ",
2256 "2026-13-01T00:00:00Z",
2257 "2026-02-29T00:00:00Z",
2258 "2025-02-29T00:00:00Z",
2259 "2026-03-01T24:00:00Z",
2260 "2026-03-01T10:00:00",
2261 "2026-03-01T10:00:00Zextra",
2262 "yesterday",
2263 "2026-03-01T10:00:60Z",
2264 "2026-03-01t10:00:00",
2265 ] {
2266 assert_eq!(parse_github_time(bad), None, "{bad}");
2267 }
2268 assert!(parse_github_time("2026-03-01t10:00:00Z").is_some());
2269 assert!(parse_github_time(" 2026-03-01T10:00:00Z\r").is_some());
2270 }
2271
2272 #[test]
2273 fn repo_json_private_false_marks_the_directory_public() {
2274 let public_bodies = [
2275 "{\"private\":false}\n",
2276 "{\r\n\"description\": \"\",\r\n\"default_branch\": null,\r\n\"Private\": false\r\n}\r\n",
2277 "{\r\n\"name\": \"\",\r\n\"PRIVATE\": \"FALSE\"\r\n}",
2278 "{\"id\":1,\"private\":\" false \",\"visibility\":\"\"}\n",
2279 ];
2280 for (index, body) in public_bodies.iter().enumerate() {
2281 let dir = scratch(&format!("repo-public-{index}"));
2282 let mut bytes = Vec::new();
2283 if index == 2 {
2284 bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]);
2285 }
2286 bytes.extend_from_slice(body.as_bytes());
2287 fs::write(dir.join("repo.json"), bytes).unwrap();
2288 let data = read_dir(&dir).unwrap();
2289 assert!(data.public, "{body}");
2290 assert!(data.runs.is_empty());
2291 assert!(data.jobs.is_empty());
2292 }
2293
2294 let private_bodies = [
2295 "{\"private\":true}\n",
2296 "{\"Private\":\"TRUE\"}\n",
2297 "{}\n",
2298 "{\"private\":null,\"name\":\"\"}\n",
2299 "{\"private\":\"\"}\n",
2300 "{\"private\":\" \",\"default_branch\":\"\"}\n",
2301 "{\r\n\"description\": \"\",\r\n\"id\": 4\r\n}\r\n",
2302 ];
2303 for (index, body) in private_bodies.iter().enumerate() {
2304 let dir = scratch(&format!("repo-private-{index}"));
2305 fs::write(dir.join("repo.json"), body).unwrap();
2306 let data = read_dir(&dir).unwrap();
2307 assert!(!data.public, "{body}");
2308 }
2309
2310 let missing = scratch("repo-missing");
2311 assert!(!read_dir(&missing).unwrap().public);
2312
2313 let broken = scratch("repo-broken");
2314 fs::write(broken.join("repo.json"), b"{private: TOKEN-ZZZ-BODY\n").unwrap();
2315 let err = read_dir(&broken).unwrap_err();
2316 assert_eq!(err.path, broken.join("repo.json"));
2317 assert_eq!(err.to_string(), "invalid JSON");
2318 assert!(!err.to_string().contains("TOKEN-ZZZ-BODY"));
2319
2320 let number = scratch("repo-number");
2321 fs::write(
2322 number.join("repo.json"),
2323 b"{\"private\":0,\"note\":\"TOKEN-NUM\"}\n",
2324 )
2325 .unwrap();
2326 let err = read_dir(&number).unwrap_err();
2327 assert_eq!(err.to_string(), "invalid JSON");
2328 assert!(!format!("{err:?}").contains("TOKEN-NUM"));
2329
2330 let word = scratch("repo-word");
2331 fs::write(word.join("repo.json"), b"{\"private\":\"yes\"}\n").unwrap();
2332 assert_eq!(read_dir(&word).unwrap_err().to_string(), "invalid JSON");
2333
2334 let dir_path = scratch("repo-directory");
2335 fs::create_dir(dir_path.join("repo.json")).unwrap();
2336 let err = read_dir(&dir_path).unwrap_err();
2337 assert_eq!(err.path, dir_path.join("repo.json"));
2338 assert_eq!(err.to_string(), "cannot read file");
2339 }
2340}