Skip to main content

diffler_ci/
model.rs

1//! The provider-agnostic CI model. Adapters normalize each forge's runs, jobs,
2//! dependency edges, and logs into these types; the host maps `RunDetail` onto a
3//! `diffler_graph::Model` for rendering.
4
5use time::OffsetDateTime;
6
7/// A run/pipeline id as the provider spells it (a GitHub run database id, a
8/// GitLab pipeline iid, …).
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub struct RunId(pub String);
11
12/// A job id within a run.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct JobId(pub String);
15
16/// Normalized job/run state, driving color and glyph. Maps 1:1 to the graph
17/// component's `NodeStatus` at the host boundary.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum JobStatus {
20    Queued,
21    Running,
22    Ok,
23    Failed,
24    Skipped,
25    Neutral,
26}
27
28impl JobStatus {
29    /// A compact status glyph for list/section rows.
30    #[must_use]
31    pub fn glyph(self) -> &'static str {
32        match self {
33            Self::Ok => "✓",
34            Self::Failed => "✗",
35            Self::Running => "●",
36            Self::Queued => "·",
37            Self::Skipped => "–",
38            Self::Neutral => "○",
39        }
40    }
41
42    /// The more severe of two statuses, so one failing matrix leg dominates an
43    /// aggregate (a run's status, a collapsed group).
44    #[must_use]
45    pub fn worse(self, other: Self) -> Self {
46        let rank = |s: Self| match s {
47            Self::Failed => 5,
48            Self::Running => 4,
49            Self::Queued => 3,
50            Self::Skipped => 2,
51            Self::Neutral => 1,
52            Self::Ok => 0,
53        };
54        if rank(self) >= rank(other) {
55            self
56        } else {
57            other
58        }
59    }
60}
61
62/// One run/pipeline as shown in the runs list.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CiRun {
65    pub id: RunId,
66    pub name: String,
67    /// The run's headline (the triggering commit's subject), if the provider
68    /// exposes one.
69    pub title: String,
70    pub branch: String,
71    pub commit: String,
72    pub author: String,
73    pub created: Option<OffsetDateTime>,
74    pub status: JobStatus,
75    pub url: Option<String>,
76}
77
78/// One job within a run, with its upstream dependencies (the DAG edges).
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct CiJob {
81    pub id: JobId,
82    pub name: String,
83    pub status: JobStatus,
84    pub needs: Vec<JobId>,
85}
86
87/// A run plus its jobs — `jobs` + each job's `needs` is the dependency graph.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct RunDetail {
90    pub run: CiRun,
91    pub jobs: Vec<CiJob>,
92}
93
94/// One step of a job, for grouping the log into the same collapsible units the
95/// forge UI shows. The public API exposes no per-step log *content*, so the host
96/// buckets log lines into steps by timestamp (`start_key` ≤ a line's timestamp).
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct LogStepMeta {
99    pub name: String,
100    pub status: JobStatus,
101    /// [`ts_sort_key`] of the step's start, the lower bound of its log lines.
102    pub start_key: u64,
103    /// Wall-clock seconds the step ran, when both endpoints are known.
104    pub duration_secs: Option<i64>,
105}
106
107/// An incremental slice of a job log. `next_offset` is where the next poll
108/// resumes; `done` is set once the job has finished and the log is complete.
109/// `steps` carries the job's step boundaries when the provider exposes them
110/// (empty otherwise). This unifies streaming, polling, and one-shot-dump sources.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct LogChunk {
113    pub text: String,
114    pub steps: Vec<LogStepMeta>,
115    pub next_offset: u64,
116    pub done: bool,
117}
118
119/// A coarse chronological sort key from an ISO-8601 timestamp: its first 14
120/// digits (`YYYYMMDDHHMMSS`), so a fractional-second line key compares against a
121/// second-resolution step key without parsing. `0` when there aren't 14 digits.
122#[must_use]
123pub fn ts_sort_key(iso: &str) -> u64 {
124    let digits: String = iso.chars().filter(char::is_ascii_digit).take(14).collect();
125    if digits.len() == 14 {
126        digits.parse().unwrap_or(0)
127    } else {
128        0
129    }
130}
131
132/// The pull/merge request for the checked-out branch, shown beside the runs so
133/// the section reflects "the branch and PR I'm on", not just a workflow.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct PullRequest {
136    pub number: u64,
137    pub title: String,
138    pub url: Option<String>,
139}
140
141/// A build artifact a run produced, as listed on the run page.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Artifact {
144    pub name: String,
145    pub size_bytes: u64,
146    /// Past its retention window: still listed, no longer downloadable.
147    pub expired: bool,
148}
149
150/// Severity of a run annotation, driving its glyph and color.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum AnnotationLevel {
153    Notice,
154    Warning,
155    Failure,
156}
157
158/// One annotation a job emitted (a `::warning`/`::error` workflow command or a
159/// check failure), tied to a file location when the provider gives one.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct Annotation {
162    pub level: AnnotationLevel,
163    pub title: String,
164    pub message: String,
165    pub path: String,
166    pub start_line: Option<u64>,
167}
168
169/// A run's page extras: the artifacts it produced and the annotations its jobs
170/// emitted. Shown below the DAG; empty for providers that don't expose them.
171#[derive(Debug, Clone, Default, PartialEq, Eq)]
172pub struct RunExtras {
173    pub artifacts: Vec<Artifact>,
174    pub annotations: Vec<Annotation>,
175}
176
177/// What a provider can actually do, so the UI degrades honestly instead of
178/// failing at runtime (hide the graph when `DagSource::None`, the follow toggle
179/// when `LogMode::Dump`, …).
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub struct Capabilities {
182    pub dag: DagSource,
183    pub logs: LogMode,
184}
185
186/// Where a provider's dependency edges come from.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum DagSource {
189    /// Edges are in the run/job API response.
190    RunApi,
191    /// Edges are only in the pipeline config file (parsed separately).
192    ConfigFile,
193    /// No dependency concept; render as a flat list.
194    None,
195}
196
197/// How a provider delivers logs.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum LogMode {
200    /// A true follow/stream.
201    Stream,
202    /// Offset/range polling.
203    Poll,
204    /// Whole log available only once the job completes.
205    Dump,
206    /// No log access.
207    None,
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn worse_picks_the_more_severe_status() {
216        assert_eq!(JobStatus::Ok.worse(JobStatus::Failed), JobStatus::Failed);
217        assert_eq!(JobStatus::Running.worse(JobStatus::Ok), JobStatus::Running);
218        assert_eq!(
219            JobStatus::Queued.worse(JobStatus::Skipped),
220            JobStatus::Queued
221        );
222        assert_eq!(JobStatus::Ok.worse(JobStatus::Ok), JobStatus::Ok);
223    }
224}