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}