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}