gx 0.8.4

CLI to manage Github Actions dependencies
Documentation
//! Addressing for managed references: which file, and where within it.
//!
//! This module is a leaf — it imports nothing else from `crate::domain`. That is
//! deliberate: both the manifest's override addressing and the lint layer's ignore
//! targets need these types, and a leaf can be depended on from either without the
//! two needing to know about each other.

/// A workflow file path with forward-slash normalization.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct WorkflowPath(String);

impl WorkflowPath {
    pub fn new<S: Into<String>>(path: S) -> Self {
        Self(path.into().replace('\\', "/"))
    }

    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for WorkflowPath {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// A workflow job identifier.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct JobId(String);

impl JobId {
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for JobId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl From<String> for JobId {
    fn from(s: String) -> Self {
        Self(s)
    }
}

impl From<&str> for JobId {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

/// A 0-based step index within a workflow job.
///
/// Wraps `u16` to make `From<StepIndex> for i64` infallible,
/// eliminating `expect("step index overflow")` in TOML serialization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct StepIndex(u16);

impl StepIndex {
    /// Returns the raw `u16` value.
    #[must_use]
    pub fn as_u16(self) -> u16 {
        self.0
    }
}

impl From<u16> for StepIndex {
    fn from(value: u16) -> Self {
        Self(value)
    }
}

impl From<StepIndex> for i64 {
    fn from(value: StepIndex) -> Self {
        Self::from(value.0)
    }
}

impl TryFrom<i64> for StepIndex {
    type Error = String;

    fn try_from(value: i64) -> Result<Self, Self::Error> {
        let raw = u16::try_from(value)
            .map_err(|_| format!("invalid step index: {value} (must be 0..=65535)"))?;
        Ok(Self(raw))
    }
}

impl TryFrom<usize> for StepIndex {
    type Error = String;

    fn try_from(value: usize) -> Result<Self, Self::Error> {
        let raw = u16::try_from(value)
            .map_err(|_| format!("invalid step index: {value} (must be 0..=65535)"))?;
        Ok(Self(raw))
    }
}

impl std::fmt::Display for StepIndex {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

/// Where within a file a reference sits.
///
/// Replaces an earlier `(Option<JobId>, Option<StepIndex>)` pair, two of whose four
/// representable combinations the scanner never produced. The composite case was
/// distinguished by `job.is_none() && step.is_some()`; that rule now holds by
/// construction rather than by convention, so the tiers of override resolution cannot
/// collide.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Slot {
    /// A step of a workflow job: `jobs.<job>.steps[<step>]`.
    WorkflowStep {
        /// The job the step belongs to.
        job: JobId,
        /// 0-based index within that job's steps.
        step: StepIndex,
    },
    /// A step of a composite action: `runs.steps[<step>]`. A composite action has no
    /// jobs, so there is no job id to carry — gx does not fabricate one.
    CompositeStep {
        /// 0-based index within `runs.steps`.
        step: StepIndex,
    },
}

impl Slot {
    /// The job this site belongs to, if its schema has jobs.
    #[must_use]
    pub fn job(&self) -> Option<&JobId> {
        match self {
            Self::WorkflowStep { job, .. } => Some(job),
            Self::CompositeStep { .. } => None,
        }
    }

    /// The step index within its list. Every slot gx records is a step; the accessor
    /// stays `Option` for callers that pair it with [`Scope::step`], which is not.
    #[must_use]
    pub fn step(&self) -> Option<StepIndex> {
        match self {
            Self::WorkflowStep { step, .. } | Self::CompositeStep { step } => Some(*step),
        }
    }
}

/// The identity of a reference site: which file, and where within it.
///
/// This is what user configuration addresses and what override resolution matches on.
/// It deliberately carries no provenance — see [`Origin`] — so that two references to
/// the same site compare and hash equal regardless of where they were read from.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Id {
    /// Relative path from repo root, e.g. `.github/workflows/ci.yml`.
    pub file: WorkflowPath,
    /// Position within that file.
    pub slot: Slot,
}

/// Which sites a piece of user configuration applies to.
///
/// [`Slot`]'s set-valued twin: a `Slot` names one site, a `Scope` selects any number of
/// them. A job-scoped override covers every step in that job, and a file-scoped one
/// covers every site in the file.
///
/// The variants are exactly the combinations a user can express. The
/// `(Option<JobId>, Option<StepIndex>)` pair this replaces admitted a fourth —
/// job-absent-but-step-present — which means a composite step on an action file and
/// nothing coherent on a workflow file. Telling those apart required classifying the
/// path; as a sum type the invalid case cannot be built, so the check belongs where the
/// user's input is parsed.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Scope {
    /// Every site in the file.
    File,
    /// Every step of one job.
    Job {
        /// The job selected.
        job: JobId,
    },
    /// One step of one job.
    JobStep {
        /// The job holding the step.
        job: JobId,
        /// 0-based index within that job's steps.
        step: StepIndex,
    },
    /// One step of a composite action, which has no jobs.
    CompositeStep {
        /// 0-based index within `runs.steps`.
        step: StepIndex,
    },
}

impl Scope {
    /// Whether this scope selects `slot`.
    ///
    /// [`Scope::File`] selects every slot, so it is not tested here — callers match the
    /// file path first, and a file-scoped override applies to whatever is in it.
    #[must_use]
    pub fn selects(&self, slot: &Slot) -> bool {
        match (self, slot) {
            (Self::File, _) => true,
            (Self::Job { job }, Slot::WorkflowStep { job: j, .. }) => job == j,
            (Self::JobStep { job, step }, Slot::WorkflowStep { job: j, step: s }) => {
                job == j && step == s
            }
            (Self::CompositeStep { step }, Slot::CompositeStep { step: s }) => step == s,
            _ => false,
        }
    }

    /// The job this scope names, if any. For the `gx.toml` `job = ` key.
    #[must_use]
    pub fn job(&self) -> Option<&JobId> {
        match self {
            Self::Job { job } | Self::JobStep { job, .. } => Some(job),
            Self::File | Self::CompositeStep { .. } => None,
        }
    }

    /// The step this scope names, if any. For the `gx.toml` `step = ` key.
    #[must_use]
    pub fn step(&self) -> Option<StepIndex> {
        match self {
            Self::JobStep { step, .. } | Self::CompositeStep { step } => Some(*step),
            Self::File | Self::Job { .. } => None,
        }
    }

    /// How specific this scope is. Higher wins when several select the same slot.
    #[must_use]
    pub fn precedence(&self) -> u8 {
        match self {
            Self::File => 0,
            Self::Job { .. } => 1,
            Self::JobStep { .. } | Self::CompositeStep { .. } => 2,
        }
    }
}

/// Where a reference was read from, for reporting.
///
/// Separate from [`Id`] because provenance must never participate in matching: an
/// override written by hand has no line number, and would otherwise fail to match the
/// same site discovered by a parse.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Origin {
    /// 1-based source line of the `uses:` scalar, when known. `None` for sites
    /// synthesized outside a parse (e.g. manifest-derived entries).
    pub line: Option<u32>,
}

#[cfg(test)]
mod tests {
    use super::{Id, JobId, Origin, Slot, StepIndex, WorkflowPath};
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher as _};

    fn hash_of<T: Hash>(value: &T) -> u64 {
        let mut hasher = DefaultHasher::new();
        value.hash(&mut hasher);
        hasher.finish()
    }

    fn workflow_site() -> Id {
        Id {
            file: WorkflowPath::new(".github/workflows/ci.yml"),
            slot: Slot::WorkflowStep {
                job: JobId::from("build"),
                step: StepIndex::from(0),
            },
        }
    }

    /// The invariant the previous `Location` could not state: identity is independent of
    /// provenance. `Location` derived `Eq` but not `Hash` precisely because its `line`
    /// field would have poisoned it.
    #[test]
    fn identity_is_independent_of_origin() {
        let (a, b) = (workflow_site(), workflow_site());
        let (origin_a, origin_b) = (Origin { line: Some(12) }, Origin { line: Some(40) });

        assert_eq!(a, b, "same site must compare equal");
        assert_eq!(hash_of(&a), hash_of(&b), "same site must hash equal");
        assert_ne!(
            origin_a, origin_b,
            "differing provenance is still observable, just not part of identity"
        );
    }

    #[test]
    fn sites_in_different_files_are_distinct() {
        let mut other = workflow_site();
        other.file = WorkflowPath::new(".github/workflows/release.yml");
        assert_ne!(workflow_site(), other);
    }

    /// A composite step and a workflow step at the same index are different addresses.
    /// Under the previous `Option` pair both were `step: Some(0)`, distinguished only by
    /// `job` being `None`.
    #[test]
    fn composite_step_is_distinct_from_workflow_step() {
        let composite = Id {
            file: WorkflowPath::new(".github/actions/setup/action.yml"),
            slot: Slot::CompositeStep {
                step: StepIndex::from(0),
            },
        };
        let workflow = Id {
            file: WorkflowPath::new(".github/actions/setup/action.yml"),
            slot: Slot::WorkflowStep {
                job: JobId::from("build"),
                step: StepIndex::from(0),
            },
        };
        assert_ne!(composite, workflow);
    }

    /// A workflow slot carries a job, a composite slot does not. This is what lets
    /// override resolution tell the two tiers apart without classifying the path.
    #[test]
    fn only_a_workflow_slot_names_a_job() {
        let workflow = Slot::WorkflowStep {
            job: JobId::from("release"),
            step: StepIndex::from(0),
        };
        let composite = Slot::CompositeStep {
            step: StepIndex::from(0),
        };
        assert_eq!(workflow.job().map(JobId::as_str), Some("release"));
        assert_eq!(composite.job(), None);
    }

    #[test]
    fn origin_defaults_to_no_line() {
        assert_eq!(Origin::default().line, None);
    }
}