Skip to main content

interprex/
source_code_configuration.rs

1//! Source-code ruleset configuration and exact-revision applied requirements.
2
3use std::{collections::HashSet, fmt::Debug};
4
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize, de::DeserializeOwned};
7
8use crate::{CommitRange, ModelError, ProviderAppId, Repository, Result};
9
10/// Whether the applied source configuration requires the change-request head
11/// to contain the current target-branch tip.
12///
13/// This fact is independent of branch freshness and mergeability. Callers
14/// combine it with observations from the code-review domain when deciding
15/// whether to request a branch update.
16#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
17#[serde(rename_all = "snake_case")]
18pub enum BranchUpdateRequirement {
19    Required,
20    NotRequired,
21}
22
23/// The provider's answer for one native required-check requirement.
24///
25/// The provider matches its native requirement against native check runs,
26/// commit statuses, or equivalent records. Consumers interpret this answer as
27/// policy; they do not repeat provider-specific matching.
28#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AppliedRequiredCheckState {
31    /// No provider record answers the requirement at the observed head.
32    Missing,
33    /// A provider record answers the requirement but has not finished.
34    Pending,
35    /// The provider reports that the requirement is satisfied.
36    Satisfied,
37    /// The provider reports that the requirement completed unsuccessfully.
38    Failed,
39}
40
41#[derive(Clone, Debug, Deserialize, Serialize)]
42struct AppliedRequiredCheckWire {
43    name: String,
44    provider_application: Option<ProviderAppId>,
45    state: AppliedRequiredCheckState,
46}
47
48/// One provider requirement and its answer at the observed head revision.
49///
50/// `name` and `provider_application` together identify the native
51/// requirement. The application identifier is opaque: consumers compare it
52/// for equality but do not parse it as a GitHub integer or infer an
53/// application from a mutable name. A missing application means the native
54/// requirement accepts an answer without selecting one application.
55#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56#[serde(
57    try_from = "AppliedRequiredCheckWire",
58    into = "AppliedRequiredCheckWire"
59)]
60pub struct AppliedRequiredCheck {
61    name: String,
62    provider_application: Option<ProviderAppId>,
63    state: AppliedRequiredCheckState,
64}
65
66impl AppliedRequiredCheck {
67    pub fn new(
68        name: impl Into<String>,
69        provider_application: Option<ProviderAppId>,
70        state: AppliedRequiredCheckState,
71    ) -> std::result::Result<Self, ModelError> {
72        let name = non_empty(name, "required check name")?;
73        Ok(Self {
74            name,
75            provider_application,
76            state,
77        })
78    }
79
80    #[must_use]
81    pub fn name(&self) -> &str {
82        &self.name
83    }
84
85    #[must_use]
86    pub const fn provider_application(&self) -> Option<&ProviderAppId> {
87        self.provider_application.as_ref()
88    }
89
90    #[must_use]
91    pub const fn state(&self) -> AppliedRequiredCheckState {
92        self.state
93    }
94}
95
96impl TryFrom<AppliedRequiredCheckWire> for AppliedRequiredCheck {
97    type Error = ModelError;
98
99    fn try_from(value: AppliedRequiredCheckWire) -> std::result::Result<Self, Self::Error> {
100        Self::new(value.name, value.provider_application, value.state)
101    }
102}
103
104impl From<AppliedRequiredCheck> for AppliedRequiredCheckWire {
105    fn from(value: AppliedRequiredCheck) -> Self {
106        Self {
107            name: value.name,
108            provider_application: value.provider_application,
109            state: value.state,
110        }
111    }
112}
113
114#[derive(Clone, Debug, Deserialize, Serialize)]
115struct AppliedSourceRequirementsWire {
116    repository: Repository,
117    target_branch: String,
118    commit_range: CommitRange,
119    required_approvals: u32,
120    branch_update: BranchUpdateRequirement,
121    required_checks: Vec<AppliedRequiredCheck>,
122}
123
124/// Requirements applied to one exact source-code subject and their answers.
125///
126/// The subject is the target repository and branch at exactly the stated base
127/// and head revisions. A provider must not substitute a newer branch tip or
128/// head revision. `required_approvals` is the strongest applicable minimum;
129/// `required_checks` contains exactly one answer for every applicable native
130/// check requirement, in stable provider-defined order. No two entries
131/// have the same name and provider-application identity.
132///
133/// A later read may return a different subject or different answers. This
134/// value is evidence about the revisions it names, not a subscription to
135/// branch state.
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
137#[serde(
138    try_from = "AppliedSourceRequirementsWire",
139    into = "AppliedSourceRequirementsWire"
140)]
141pub struct AppliedSourceRequirements {
142    repository: Repository,
143    target_branch: String,
144    commit_range: CommitRange,
145    required_approvals: u32,
146    branch_update: BranchUpdateRequirement,
147    required_checks: Vec<AppliedRequiredCheck>,
148}
149
150impl AppliedSourceRequirements {
151    #[allow(clippy::too_many_arguments)]
152    pub fn new(
153        repository: Repository,
154        target_branch: impl Into<String>,
155        commit_range: CommitRange,
156        required_approvals: u32,
157        branch_update: BranchUpdateRequirement,
158        required_checks: Vec<AppliedRequiredCheck>,
159    ) -> std::result::Result<Self, ModelError> {
160        let target_branch = non_empty(target_branch, "target branch")?;
161        non_empty(&commit_range.base_sha, "base sha")?;
162        non_empty(&commit_range.head_sha, "head sha")?;
163
164        let mut identities = HashSet::with_capacity(required_checks.len());
165        for check in &required_checks {
166            let identity = (check.name.clone(), check.provider_application.clone());
167            if !identities.insert(identity) {
168                return Err(ModelError::DuplicateRequiredCheck {
169                    name: check.name.clone(),
170                });
171            }
172        }
173
174        Ok(Self {
175            repository,
176            target_branch,
177            commit_range,
178            required_approvals,
179            branch_update,
180            required_checks,
181        })
182    }
183
184    #[must_use]
185    pub const fn repository(&self) -> &Repository {
186        &self.repository
187    }
188
189    #[must_use]
190    pub fn target_branch(&self) -> &str {
191        &self.target_branch
192    }
193
194    #[must_use]
195    pub const fn commit_range(&self) -> &CommitRange {
196        &self.commit_range
197    }
198
199    #[must_use]
200    pub const fn required_approvals(&self) -> u32 {
201        self.required_approvals
202    }
203
204    #[must_use]
205    pub const fn branch_update(&self) -> BranchUpdateRequirement {
206        self.branch_update
207    }
208
209    #[must_use]
210    pub fn required_checks(&self) -> &[AppliedRequiredCheck] {
211        &self.required_checks
212    }
213}
214
215impl TryFrom<AppliedSourceRequirementsWire> for AppliedSourceRequirements {
216    type Error = ModelError;
217
218    fn try_from(value: AppliedSourceRequirementsWire) -> std::result::Result<Self, Self::Error> {
219        Self::new(
220            value.repository,
221            value.target_branch,
222            value.commit_range,
223            value.required_approvals,
224            value.branch_update,
225            value.required_checks,
226        )
227    }
228}
229
230impl From<AppliedSourceRequirements> for AppliedSourceRequirementsWire {
231    fn from(value: AppliedSourceRequirements) -> Self {
232        Self {
233            repository: value.repository,
234            target_branch: value.target_branch,
235            commit_range: value.commit_range,
236            required_approvals: value.required_approvals,
237            branch_update: value.branch_update,
238            required_checks: value.required_checks,
239        }
240    }
241}
242
243/// Provider capability for reading and applying complete native rulesets.
244///
245/// The associated type preserves the provider's complete source-code
246/// configuration, including provider-specific rule parameters and unknown
247/// fields. A configuration tool that uses this trait chooses a concrete
248/// provider and its `Ruleset` type; policy consumers use
249/// [`AppliedSourceRequirementsProvider`] instead.
250#[async_trait]
251pub trait SourceCodeConfigurationProvider: Send + Sync {
252    type Ruleset: Clone + Debug + DeserializeOwned + Serialize + Send + Sync + 'static;
253
254    /// Reads every ruleset visible for `repository` in stable provider order.
255    ///
256    /// The provider completely paginates the collection and retains fields it
257    /// cannot interpret. It returns an explicit
258    /// [`crate::ProviderError::Unsupported`] error when it cannot provide the
259    /// complete native configuration.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`crate::ProviderError::NotFound`] when the repository is
264    /// absent, [`crate::ProviderError::MissingCredential`] when the operation
265    /// lacks credentials, [`crate::ProviderError::Unrepresentable`] when a
266    /// response cannot be retained without loss,
267    /// [`crate::ProviderError::Unsupported`] when this provider has no
268    /// complete ruleset implementation, and
269    /// [`crate::ProviderError::External`] for provider read failures.
270    async fn read_rulesets(&self, repository: &Repository) -> Result<Vec<Self::Ruleset>>;
271
272    /// Creates or replaces the native ruleset identified by `ruleset`.
273    ///
274    /// The provider-specific value carries the identity and complete desired
275    /// configuration. The returned value is the provider's complete accepted
276    /// representation. The operation never fills omitted fields with
277    /// Interprex defaults.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`crate::ProviderError::InvalidInput`] when `ruleset` is not a
282    /// complete writable configuration, [`crate::ProviderError::NotFound`]
283    /// when its repository or existing native identity is absent,
284    /// [`crate::ProviderError::MissingCredential`] when the operation lacks
285    /// credentials, [`crate::ProviderError::Unrepresentable`] when accepted
286    /// provider data cannot be read or verified without loss,
287    /// [`crate::ProviderError::Unsupported`] when this provider has no complete
288    /// ruleset implementation, and [`crate::ProviderError::External`] for
289    /// provider refusal or transport failure.
290    async fn apply_ruleset(
291        &self,
292        repository: &Repository,
293        ruleset: &Self::Ruleset,
294    ) -> Result<Self::Ruleset>;
295}
296
297/// Provider capability for reading provider-neutral requirements already
298/// applied to one exact source-code subject.
299///
300/// The trait has no associated types and is object-safe. Callers can therefore
301/// select a provider at runtime while receiving only the facts needed for
302/// policy. Native ruleset configuration remains behind
303/// [`SourceCodeConfigurationProvider`].
304#[async_trait]
305pub trait AppliedSourceRequirementsProvider: Send + Sync {
306    /// Reads the requirements applied to the exact requested revisions.
307    ///
308    /// The returned value repeats `repository`, `target_branch`, and
309    /// `commit_range`. The provider must return those exact values or an error;
310    /// it must not answer for a newer branch tip or head. Required checks are
311    /// completely matched against native records and retain stable
312    /// provider-defined requirement order.
313    ///
314    /// # Errors
315    ///
316    /// Returns [`crate::ProviderError::InvalidInput`] for an empty branch or
317    /// revision, [`crate::ProviderError::NotFound`] when the repository,
318    /// branch, or either revision is absent,
319    /// [`crate::ProviderError::MissingCredential`] when the operation lacks
320    /// credentials, [`crate::ProviderError::Unrepresentable`] when an
321    /// applicable native requirement or answer cannot be represented,
322    /// [`crate::ProviderError::Unsupported`] when this provider has no applied
323    /// requirements implementation, and [`crate::ProviderError::External`]
324    /// for provider read failures.
325    async fn applied_requirements(
326        &self,
327        repository: &Repository,
328        target_branch: &str,
329        commit_range: &CommitRange,
330    ) -> Result<AppliedSourceRequirements>;
331}
332
333fn non_empty(
334    value: impl Into<String>,
335    field: &'static str,
336) -> std::result::Result<String, ModelError> {
337    let value = value.into();
338    if value.is_empty() {
339        Err(ModelError::Empty { field })
340    } else {
341        Ok(value)
342    }
343}