diffler 0.16.1

Standalone magit-style git TUI and code reviewer, on its own or with your coding agent
Documentation
//! The provider-agnostic CI model. Adapters normalize each forge's runs, jobs,
//! dependency edges, and logs into these types; the host maps `RunDetail` onto a
//! `crate::graph::Model` for rendering.

use time::OffsetDateTime;

/// A run/pipeline id as the provider spells it (a GitHub run database id, a
/// GitLab pipeline iid, …).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct RunId(pub String);

/// A job id within a run.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct JobId(pub String);

/// Normalized job/run state, driving color and glyph. Maps 1:1 to the graph
/// component's `NodeStatus` at the host boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JobStatus {
    Queued,
    Running,
    Ok,
    Failed,
    Skipped,
    Neutral,
}

impl JobStatus {
    /// A compact status glyph for list/section rows.
    #[must_use]
    pub fn glyph(self) -> &'static str {
        match self {
            Self::Ok => "✓",
            Self::Failed => "×",
            Self::Running => "●",
            Self::Queued => "·",
            Self::Skipped => "–",
            Self::Neutral => "○",
        }
    }

    /// The more severe of two statuses, so one failing matrix leg dominates an
    /// aggregate (a run's status, a collapsed group).
    #[must_use]
    pub fn worse(self, other: Self) -> Self {
        let rank = |s: Self| match s {
            Self::Failed => 5,
            Self::Running => 4,
            Self::Queued => 3,
            Self::Skipped => 2,
            Self::Neutral => 1,
            Self::Ok => 0,
        };
        if rank(self) >= rank(other) {
            self
        } else {
            other
        }
    }
}

/// One run/pipeline as shown in the runs list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CiRun {
    pub id: RunId,
    pub name: String,
    /// The run's headline (the triggering commit's subject), if the provider
    /// exposes one.
    pub title: String,
    pub branch: String,
    pub commit: String,
    pub author: String,
    pub created: Option<OffsetDateTime>,
    pub status: JobStatus,
    pub url: Option<String>,
    /// The git remote this run came from, set when several remotes are
    /// aggregated; `None` for a single-remote repo.
    pub remote: Option<String>,
}

/// One job within a run, with its upstream dependencies (the DAG edges).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CiJob {
    pub id: JobId,
    pub name: String,
    pub status: JobStatus,
    pub needs: Vec<JobId>,
    /// How long the job took, or has been running so far. `None` where the
    /// forge reports no times, or before it starts.
    pub duration_secs: Option<i64>,
    /// The job's `strategy.matrix` legs, one per run the forge actually
    /// reported under this job, when there was more than one; empty for a job
    /// that ran once. `status` and `duration_secs` above stay the aggregate
    /// across every leg, so a plain rendering (no fold container) still reads
    /// correctly without looking at this field.
    pub legs: Vec<CiJobLeg>,
}

/// One matrix leg of a [`CiJob`]: its own display name (the leg's parameters,
/// not the job's name repeated), status and duration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CiJobLeg {
    /// The run job this leg ran as, which its log is fetched by.
    pub id: JobId,
    pub name: String,
    pub status: JobStatus,
    pub duration_secs: Option<i64>,
}

/// A span of time as `13s` or `1m03s`: how a job or step reads wherever one is
/// shown.
pub fn fmt_duration(secs: i64) -> String {
    let secs = secs.max(0);
    if secs < 60 {
        format!("{secs}s")
    } else {
        format!("{}m{:02}s", secs / 60, secs % 60)
    }
}

/// A run plus its jobs: `jobs` + each job's `needs` is the dependency graph.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunDetail {
    pub run: CiRun,
    pub jobs: Vec<CiJob>,
}

/// One step of a job, for grouping the log into the same collapsible units the
/// forge UI shows. The public API exposes no per-step log *content*, so the host
/// buckets log lines into steps by timestamp (`start_key` ≤ a line's timestamp).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogStepMeta {
    pub name: String,
    pub status: JobStatus,
    /// [`ts_sort_key`] of the step's start, the lower bound of its log lines.
    pub start_key: u64,
    /// Wall-clock seconds the step ran, when both endpoints are known.
    pub duration_secs: Option<i64>,
}

/// An incremental slice of a job log. `next_offset` is where the next poll
/// resumes; `done` is set once the job has finished and the log is complete.
/// `steps` carries the job's step boundaries when the provider exposes them
/// (empty otherwise). This unifies streaming, polling, and one-shot-dump sources.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogChunk {
    pub text: String,
    pub steps: Vec<LogStepMeta>,
    pub next_offset: u64,
    pub done: bool,
}

/// A coarse chronological sort key from an ISO-8601 timestamp: its first 14
/// digits (`YYYYMMDDHHMMSS`), so a fractional-second line key compares against a
/// second-resolution step key without parsing. `0` when there aren't 14 digits.
#[must_use]
pub fn ts_sort_key(iso: &str) -> u64 {
    let digits: String = iso.chars().filter(char::is_ascii_digit).take(14).collect();
    if digits.len() == 14 {
        digits.parse().unwrap_or(0)
    } else {
        0
    }
}

/// The pull/merge request for the checked-out branch, shown beside the runs so
/// the section reflects "the branch and PR I'm on", not just a workflow.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullRequest {
    pub number: u64,
    pub title: String,
    pub url: Option<String>,
    /// Branch the PR merges into.
    pub base_ref: String,
    /// The PR's source branch.
    pub head_ref: String,
    /// The PR head commit at fetch time; the diff is `merge-base..head`.
    pub head_oid: String,
    pub author: String,
}

/// A line-anchored PR review comment from the forge.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrComment {
    pub id: String,
    pub path: String,
    /// 1-based line on the side the comment anchors to; `None` for file-level.
    pub line: Option<u32>,
    /// First line of a multi-line comment's range.
    pub start_line: Option<u32>,
    /// Anchored to the new side (`true`) or the old side of the diff.
    pub new_side: bool,
    pub body: String,
    pub author: String,
    /// Forge id of the comment this replies to; `None` for thread roots.
    pub reply_to: Option<String>,
    /// The forge's thread handle, set on thread roots where the provider
    /// exposes one; what `resolve_pr_thread` takes.
    pub thread_id: Option<String>,
    /// The thread is marked resolved on the forge (roots only).
    pub resolved: bool,
    pub at: u64,
}

/// A build artifact a run produced, as listed on the run page.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
    pub name: String,
    pub size_bytes: u64,
    /// Past its retention window: still listed, no longer downloadable.
    pub expired: bool,
}

/// Severity of a run annotation, driving its glyph and color.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnnotationLevel {
    Notice,
    Warning,
    Failure,
}

/// One annotation a job emitted (a `::warning`/`::error` workflow command or a
/// check failure), tied to a file location when the provider gives one.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Annotation {
    pub level: AnnotationLevel,
    pub title: String,
    pub message: String,
    pub path: String,
    pub start_line: Option<u64>,
}

/// A run's page extras: the artifacts it produced and the annotations its jobs
/// emitted. Shown below the DAG; empty for providers that don't expose them.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunExtras {
    pub artifacts: Vec<Artifact>,
    pub annotations: Vec<Annotation>,
}

/// What a provider can actually do, so the UI degrades honestly instead of
/// failing at runtime (hide the graph when `DagSource::None`, the follow toggle
/// when `LogMode::Dump`, …).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Capabilities {
    pub dag: DagSource,
    pub logs: LogMode,
    /// diffler can tell this forge a review thread is resolved. Where it
    /// cannot, a resolution stays local to the review session.
    pub resolve_threads: bool,
    /// This forge can anchor a comment to a whole file instead of a line.
    /// Where it can't, a line-less comment is held back from a submit.
    pub file_comments: bool,
}

/// Where a provider's dependency edges come from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DagSource {
    /// Edges are in the run/job API response.
    RunApi,
    /// Edges are only in the pipeline config file (parsed separately).
    ConfigFile,
    /// No dependency concept; render as a flat list.
    None,
}

/// How a provider delivers logs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogMode {
    /// A true follow/stream.
    Stream,
    /// Offset/range polling.
    Poll,
    /// Whole log available only once the job completes.
    Dump,
    /// No log access.
    None,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn worse_picks_the_more_severe_status() {
        assert_eq!(JobStatus::Ok.worse(JobStatus::Failed), JobStatus::Failed);
        assert_eq!(JobStatus::Running.worse(JobStatus::Ok), JobStatus::Running);
        assert_eq!(
            JobStatus::Queued.worse(JobStatus::Skipped),
            JobStatus::Queued
        );
        assert_eq!(JobStatus::Ok.worse(JobStatus::Ok), JobStatus::Ok);
    }
}