1use super::{Config, Protection, invalid};
4use crate::error::RkError;
5use crate::landing::Integration;
6
7pub struct Floor {
9 pub key: &'static str,
11 pub minimum: &'static str,
13 pub heading: &'static str,
15 accepts: fn(&Protection) -> bool,
16}
17
18const SQUASH: &str = "The trunk takes one validated squash commit at a time";
20
21const REQUEST: &str = "A forge-integrated trunk merges through a checked request";
23
24const SHARED: &[Floor] = &[
28 Floor {
29 key: "protection.tag_pattern",
30 minimum: "refs/tags/v* or refs/tags/*, covering every published version",
31 heading: "A published version is immutable",
32 accepts: |p| matches!(p.tag_pattern.as_str(), "refs/tags/v*" | "refs/tags/*"),
33 },
34 Floor {
35 key: "protection.bypass_actors",
36 minimum: "empty",
37 heading: SQUASH,
38 accepts: |p| p.bypass_actors.is_empty(),
39 },
40 Floor {
41 key: "protection.allowed_merge_methods",
42 minimum: "exactly [squash]",
43 heading: SQUASH,
44 accepts: |p| p.allowed_merge_methods == ["squash"],
45 },
46 Floor {
47 key: "protection.github.squash_title_source",
48 minimum: "PR_TITLE",
49 heading: SQUASH,
50 accepts: |p| p.github.squash_title_source == "PR_TITLE",
51 },
52 Floor {
53 key: "protection.github.squash_body_source",
54 minimum: "PR_BODY",
55 heading: SQUASH,
56 accepts: |p| p.github.squash_body_source == "PR_BODY",
57 },
58 Floor {
59 key: "protection.gitlab.merge_method",
60 minimum: "ff",
61 heading: SQUASH,
62 accepts: |p| p.gitlab.merge_method == "ff",
63 },
64 Floor {
65 key: "protection.gitlab.squash_option",
66 minimum: "always",
67 heading: SQUASH,
68 accepts: |p| p.gitlab.squash_option == "always",
69 },
70 Floor {
71 key: "protection.gitlab.squash_commit_template",
72 minimum: "references %{title}",
73 heading: SQUASH,
74 accepts: |p| p.gitlab.squash_commit_template.contains("%{title}"),
79 },
80 Floor {
81 key: "protection.gitlab.merge_access_level",
82 minimum: "at least 30",
83 heading: SQUASH,
84 accepts: |p| p.gitlab.merge_access_level >= 30,
85 },
86];
87
88const FORGE_ONLY: &[Floor] = &[
91 Floor {
92 key: "protection.strict_required_status_checks",
93 minimum: "true",
94 heading: REQUEST,
95 accepts: |p| p.strict_required_status_checks,
96 },
97 Floor {
98 key: "protection.owned_trunk_rules",
99 minimum: "contains deletion, non_fast_forward, pull_request, required_status_checks",
100 heading: REQUEST,
101 accepts: |p| {
102 [
103 "deletion",
104 "non_fast_forward",
105 "pull_request",
106 "required_status_checks",
107 ]
108 .iter()
109 .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
110 },
111 },
112 Floor {
113 key: "protection.gitlab.push_access_level",
114 minimum: "0 (no direct pushes)",
115 heading: REQUEST,
116 accepts: |p| p.gitlab.push_access_level == 0,
117 },
118 Floor {
119 key: "protection.required_approving_review_count",
120 minimum: "at least 0",
121 heading: REQUEST,
122 accepts: |p| p.required_approving_review_count >= 0,
123 },
124 Floor {
125 key: "protection.dismiss_stale_reviews_on_push",
126 minimum: "false; true is stricter",
127 heading: REQUEST,
128 accepts: |p| {
129 let _ = p.dismiss_stale_reviews_on_push;
130 true
131 },
132 },
133 Floor {
134 key: "protection.require_code_owner_review",
135 minimum: "false; true is stricter",
136 heading: REQUEST,
137 accepts: |p| {
138 let _ = p.require_code_owner_review;
139 true
140 },
141 },
142 Floor {
143 key: "protection.require_last_push_approval",
144 minimum: "false; true is stricter",
145 heading: REQUEST,
146 accepts: |p| {
147 let _ = p.require_last_push_approval;
148 true
149 },
150 },
151];
152
153const LOCAL_ONLY: &[Floor] = &[
164 Floor {
165 key: "protection.owned_trunk_rules",
166 minimum: "contains deletion, non_fast_forward",
167 heading: SQUASH,
168 accepts: |p| {
169 ["deletion", "non_fast_forward"]
170 .iter()
171 .all(|rule| p.owned_trunk_rules.iter().any(|owned| owned == rule))
172 },
173 },
174 Floor {
175 key: "protection.gitlab.push_access_level",
176 minimum: "at least 40 (maintainers alone); 0 is stricter and closes the trunk entirely",
181 heading: SQUASH,
182 accepts: |p| p.gitlab.push_access_level == 0 || p.gitlab.push_access_level >= 40,
183 },
184];
185
186#[must_use]
188pub fn floors(integration: Integration) -> Vec<&'static Floor> {
189 let extra = match integration {
190 Integration::Forge => FORGE_ONLY,
191 Integration::Local => LOCAL_ONLY,
192 };
193 SHARED.iter().chain(extra).collect()
194}
195
196pub fn check(config: &Config) -> Result<(), RkError> {
206 let integration = config.git.integration.unwrap_or(Integration::Forge);
207 for floor in floors(integration) {
208 if !(floor.accepts)(&config.protection) {
209 return Err(invalid(format!(
210 "{}: floor is {}; see rk method invariants ({})",
211 floor.key, floor.minimum, floor.heading
212 )));
213 }
214 }
215 Ok(())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::{check, floors};
221 use crate::config::Config;
222 use crate::landing::Integration;
223
224 #[test]
225 fn every_floor_names_a_real_invariant_heading() {
226 let chapter = crate::embedded::METHOD
227 .get_file("01-invariants.md")
228 .expect("the invariants ship")
229 .contents_utf8()
230 .expect("prose is utf8");
231 for integration in [Integration::Forge, Integration::Local] {
232 for floor in floors(integration) {
233 assert!(
234 chapter
235 .lines()
236 .any(|line| line.strip_prefix("## ") == Some(floor.heading)),
237 "{}: {}",
238 floor.key,
239 floor.heading
240 );
241 }
242 }
243 check(&Config::default()).expect("defaults meet every floor");
244 let mut local = Config::default();
245 local.git.integration = Some(Integration::Local);
246 check(&local).expect("defaults meet every local floor too");
247 }
248
249 #[test]
250 fn a_value_below_a_floor_refuses_naming_the_invariants() {
251 let mut config = Config::default();
252 config.protection.allowed_merge_methods.push("merge".into());
253 let error = check(&config)
254 .expect_err("merge violates squash only")
255 .to_string();
256 for expected in [
257 "protection.allowed_merge_methods",
258 "floor",
259 "squash",
260 "rk method invariants",
261 ] {
262 assert!(error.contains(expected), "{error}");
263 }
264 }
265
266 #[test]
267 fn a_stricter_value_passes_the_floor() {
268 let mut config = Config::default();
269 config.protection.required_approving_review_count = 3;
270 config.protection.dismiss_stale_reviews_on_push = true;
271 config.protection.require_code_owner_review = true;
272 config.protection.require_last_push_approval = true;
273 config.protection.gitlab.merge_access_level = 40;
274 config.protection.tag_pattern = "refs/tags/*".into();
275 config
276 .protection
277 .owned_trunk_rules
278 .push("required_signatures".into());
279 check(&config).expect("a target can be stricter");
280 }
281
282 #[test]
283 fn one_policy_is_judged_by_the_recorded_integration_mode() {
284 let mut config = Config::default();
285 config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
286 config.git.integration = Some(Integration::Forge);
287 let error = check(&config)
288 .expect_err("a forge-integrated trunk needs the request rule")
289 .to_string();
290 assert!(error.contains("protection.owned_trunk_rules"), "{error}");
291 assert!(error.contains("pull_request"), "{error}");
292 config.git.integration = Some(Integration::Local);
293 check(&config).expect("the same policy holds under local integration");
294 }
295
296 #[test]
297 fn a_silent_config_is_judged_as_forge_integrated() {
298 let mut config = Config::default();
299 config.protection.owned_trunk_rules = vec!["deletion".into(), "non_fast_forward".into()];
300 config.git.integration = None;
301 check(&config).expect_err("silence is judged by the stricter table");
302 }
303
304 #[test]
305 fn a_local_target_still_refuses_a_developer_wide_trunk_push() {
306 let mut config = Config::default();
307 config.git.integration = Some(Integration::Local);
308 config.protection.gitlab.push_access_level = 30;
309 let error = check(&config)
310 .expect_err("developer level is everyone")
311 .to_string();
312 assert!(
313 error.contains("protection.gitlab.push_access_level"),
314 "{error}"
315 );
316 config.protection.gitlab.push_access_level = 40;
317 check(&config).expect("maintainers alone is a restriction");
318 }
319
320 #[test]
321 fn the_tag_floors_hold_under_both_integration_modes() {
322 for integration in [Integration::Forge, Integration::Local] {
323 let mut config = Config::default();
324 config.git.integration = Some(integration);
325 config.protection.tag_pattern = "refs/tags/release-*".into();
326 let error = check(&config)
327 .expect_err("a pattern missing published versions refuses")
328 .to_string();
329 assert!(error.contains("protection.tag_pattern"), "{error}");
330 }
331 }
332}