Skip to main content

ci_bill/
api.rs

1//! Saved GitHub Actions API responses.
2//!
3//! `--runs DIR` reads `runs-*.json` (a `GET /repos/{owner}/{repo}/actions/runs`
4//! body) and `jobs-<run_id>*.json` (a
5//! `GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs?filter=all` body).
6//! Several files are pages of one list. Only files directly in the directory
7//! are read. Bodies from `attempts/{n}/jobs` are a different list and are
8//! not mixed in.
9//!
10//! `repo.json` in that directory is the `GET /repos/{owner}/{repo}` body.
11//! `private: false` marks the repository public. A missing file, or a missing
12//! `private` field, leaves the repository billed at list price.
13//!
14//! A run `path` is stored so it matches a CSV `workflow_path`: the `@ref`
15//! suffix (including `@refs/heads/...`) and an `owner/repo/` prefix are removed.
16//! `head_branch` and `pull_requests[].number` are kept when present so two runs
17//! of one workflow can be compared on the same branch or pull request.
18
19use std::collections::HashSet;
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use serde_json::{Map, Value};
24
25use crate::money::Micros;
26
27/// Phrase used when a job has not finished.
28pub const IN_PROGRESS: &str = "in progress";
29
30/// Workflow path used when a job has no matching run record.
31pub const UNKNOWN_WORKFLOW: &str = "(unknown workflow)";
32
33/// One workflow run from `workflow_runs[]`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct WorkflowRun {
36    pub id: u64,
37    /// Linked workflow path: `@ref` and an `owner/repo/` prefix are removed.
38    pub path: String,
39    /// `owner/repo` taken from the raw run path before linking. `None` when
40    /// the path has no two-segment prefix in front of `.github/workflows`.
41    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    /// Branch name from `head_branch`. Missing and null stay `None`.
48    pub head_branch: Option<String>,
49    /// Pull request numbers from `pull_requests[].number`, in file order.
50    /// Missing, null, and an empty array are an empty list.
51    pub pull_requests: Vec<u64>,
52}
53
54impl WorkflowRun {
55    /// This run's linked path is the CSV `workflow_path` (ASCII case-insensitive).
56    pub fn links_to(&self, workflow_path: &str) -> bool {
57        workflow_paths_match(&self.path, workflow_path)
58    }
59}
60
61/// One job from `jobs[]`.
62///
63/// `completed_at` is `None` when the field is missing or null. A blank
64/// completion time is stored as given and still counts as in progress.
65#[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    /// A finished job has a non-blank completion time.
80    ///
81    /// Missing, null, and blank `completed_at` values are still in progress
82    /// and do not count.
83    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/// Runs and jobs loaded from one directory.
91///
92/// `jobs` keeps finished jobs and in-progress jobs. [`Job::counts`] selects
93/// the finished ones.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct ApiData {
96    pub runs: Vec<WorkflowRun>,
97    pub jobs: Vec<Job>,
98    /// `true` when `repo.json` says `private` is false.
99    ///
100    /// Standard runner SKUs then invoice at zero and keep their list price.
101    /// Larger runners stay charged. A missing file is `false`.
102    pub public: bool,
103}
104
105impl ApiData {
106    /// Finished jobs, in storage order.
107    pub fn counted_jobs(&self) -> impl Iterator<Item = &Job> {
108        self.jobs.iter().filter(|job| job.counts())
109    }
110
111    /// Jobs with no `completed_at`, in storage order.
112    pub fn in_progress_jobs(&self) -> impl Iterator<Item = &Job> {
113        self.jobs.iter().filter(|job| !job.counts())
114    }
115
116    /// Linked workflow path for `run_id`, or [`UNKNOWN_WORKFLOW`].
117    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    /// A job on the latest attempt whose `started_at` is before `run_started_at`.
126    ///
127    /// GitHub can carry a finished job onto the new attempt. That copy adds
128    /// no cost. A job from an earlier attempt stays billable. A missing run,
129    /// a different attempt, or a missing or unreadable timestamp is not a copy.
130    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    /// `Some(0)` when `job` is an inherited copy.
138    pub fn inherited_copy_net(&self, job: &Job) -> Option<Micros> {
139        self.is_inherited_copy(job).then_some(Micros::ZERO)
140    }
141
142    /// Finished jobs that add cost. Inherited copies are omitted.
143    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
150/// Drop `@ref` and an `owner/repo/` prefix so a run path matches a CSV workflow path.
151///
152/// `@main` and `@refs/heads/feature/x` both leave the workflow file. The prefix
153/// is removed only when exactly two segments sit in front of `.github/workflows`.
154/// Matching later is ASCII case-insensitive; this function keeps the workflow
155/// path's own case.
156pub 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
167/// `owner/repo` from a raw Actions run path, before `@ref` and the workflow prefix are removed.
168///
169/// The prefix counts only when exactly two segments sit in front of
170/// `.github/workflows`. Any other shape has no repository.
171pub 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
193/// Both paths refer to the same workflow file after linking.
194pub 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/// The runs directory or one JSON file could not be read. Exit code 2.
201///
202/// Messages name the kind of problem. They do not include the file body.
203#[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
270/// Read every runs page and `jobs?filter=all` page in `dir`.
271///
272/// Names are sorted, then pages are merged. A later file that repeats a run
273/// id is an error. A repeated job id counts once: the first record in file-name
274/// order, then array order, is kept. Each job's `run_id` must match the
275/// `jobs-<run_id>` prefix of its file (`jobs-15-2.json` is run 15,
276/// `jobs-150.json` is run 150). Files from `attempts/{n}/jobs` are not read.
277pub 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                // The same job id on another filter=all page counts once.
305                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
319/// `repo.json` `private: false` means the repository is public.
320///
321/// The key match ignores ASCII case. A boolean and the strings `true` and
322/// `false` are accepted. A missing file, a missing field, null, or a blank
323/// string is not public. Any other value is invalid JSON.
324fn 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
405/// `jobs-<run_id>*.json` from `jobs?filter=all`, not from `attempts/{n}/jobs`.
406///
407/// A file-name segment `attempt` or `attempts` is the per-attempt endpoint
408/// (`jobs-15-attempts-1.json`). Those files are left unread. Pagination names
409/// such as `jobs-15-2.json` stay included. Subdirectories, including
410/// `attempts/{n}/`, are not listed.
411fn 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
423/// Run id encoded by `jobs-<run_id>*.json`.
424///
425/// The id is the leading digit run. `jobs-15-2.json` is run 15 and
426/// `jobs-150.json` is run 150.
427pub(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/// Parse one `workflow_runs` page body, the same way [`read_dir`] parses each `runs-*.json` file.
442#[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/// Parse one `jobs` page body, the same way [`read_dir`] parses each `jobs-*.json` file.
500#[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
609/// Nanoseconds since the Unix epoch. `None` when the text is not a timestamp.
610pub(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
732/// Days since 1970-01-01. Howard Hinnant's civil-from-days algorithm.
733fn 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}