1pub mod floors;
5
6use std::collections::BTreeMap;
7use std::fmt::Write as _;
8use std::path::Path;
9
10use crate::diagnostic::{Diagnostic, Reason};
11use crate::error::RkError;
12use crate::landing::{Style, Workflow};
13use serde::Deserialize;
14
15pub const CONFIG_PATH: &str = ".release-kit/config.toml";
17pub const SCHEMA_VERSION: i64 = 1;
19
20#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
22#[serde(deny_unknown_fields, default)]
23pub struct Config {
24 pub schema_version: i64,
26 pub project: Project,
28 pub landing: Landing,
30 pub security: Security,
32 pub setup: Setup,
34 pub protection: Protection,
36}
37
38impl Default for Config {
39 fn default() -> Self {
40 Self {
41 schema_version: SCHEMA_VERSION,
42 project: Project::default(),
43 landing: Landing::default(),
44 security: Security::default(),
45 setup: Setup::default(),
46 protection: Protection::default(),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
53#[serde(deny_unknown_fields, default)]
54pub struct Project {
55 pub repo: String,
57 pub forge: String,
59 pub tech: String,
61 pub trunk: Option<String>,
65}
66
67pub const TRUNK_DEFAULT: &str = "master";
69
70pub const LINE_PREFIX_DEFAULT: &str = "release/";
72
73#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
75#[serde(deny_unknown_fields, default)]
76pub struct Landing {
77 pub workflow: Option<Workflow>,
79 pub style: Option<Style>,
81 pub nix: Option<bool>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
87#[serde(deny_unknown_fields, default)]
88pub struct Security {
89 pub advisories: String,
91 pub contact: String,
93 pub response: String,
95}
96
97impl Default for Security {
98 fn default() -> Self {
99 Self {
100 advisories: String::new(),
101 contact: String::new(),
102 response: "best-effort".into(),
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
109#[serde(deny_unknown_fields, default)]
110pub struct Setup {
111 pub required_check: String,
113 pub retired_branches: Vec<String>,
115 pub line_prefix: Option<String>,
119 pub release_lines: bool,
121 pub excluded_steps: BTreeMap<String, String>,
126 pub bot: Bot,
128}
129
130impl Default for Setup {
131 fn default() -> Self {
132 Self {
133 required_check: String::new(),
134 retired_branches: vec!["main".into(), "develop".into()],
135 line_prefix: None,
136 release_lines: false,
137 excluded_steps: BTreeMap::new(),
138 bot: Bot::default(),
139 }
140 }
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
150#[serde(deny_unknown_fields, default)]
151pub struct Bot {
152 pub app_id: String,
154 #[serde(default, skip_serializing)]
159 pub installation_id: Option<i64>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
164#[serde(deny_unknown_fields, default)]
165#[allow(clippy::struct_excessive_bools)]
167pub struct Protection {
168 pub trunk_ruleset: Option<String>,
172 pub tag_ruleset: String,
174 pub lines_ruleset: String,
176 pub title_check: String,
178 pub tag_pattern: String,
180 pub bypass_actors: Vec<String>,
182 pub allowed_merge_methods: Vec<String>,
184 pub strict_required_status_checks: bool,
186 pub owned_trunk_rules: Vec<String>,
188 pub required_approving_review_count: i64,
190 pub dismiss_stale_reviews_on_push: bool,
192 pub require_code_owner_review: bool,
194 pub require_last_push_approval: bool,
196 pub github: Github,
198 pub gitlab: Gitlab,
200}
201
202impl Default for Protection {
203 fn default() -> Self {
204 Self {
205 trunk_ruleset: None,
206 tag_ruleset: "release-tags".into(),
207 lines_ruleset: "release-lines".into(),
208 title_check: "pr-title".into(),
209 tag_pattern: "refs/tags/v*".into(),
210 bypass_actors: Vec::new(),
211 allowed_merge_methods: vec!["squash".into()],
212 strict_required_status_checks: true,
213 owned_trunk_rules: vec![
214 "deletion".into(),
215 "non_fast_forward".into(),
216 "pull_request".into(),
217 "required_status_checks".into(),
218 ],
219 required_approving_review_count: 0,
220 dismiss_stale_reviews_on_push: false,
221 require_code_owner_review: false,
222 require_last_push_approval: false,
223 github: Github::default(),
224 gitlab: Gitlab::default(),
225 }
226 }
227}
228
229impl Protection {
230 #[must_use]
233 pub fn trunk_ruleset(&self, trunk: &str) -> String {
234 self.trunk_ruleset
235 .clone()
236 .unwrap_or_else(|| format!("{trunk}-protection"))
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
242#[serde(deny_unknown_fields, default)]
243pub struct Github {
244 pub squash_title_source: String,
246 pub squash_body_source: String,
248}
249
250impl Default for Github {
251 fn default() -> Self {
252 Self {
253 squash_title_source: "PR_TITLE".into(),
254 squash_body_source: "PR_BODY".into(),
255 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
261#[serde(deny_unknown_fields, default)]
262pub struct Gitlab {
263 pub merge_method: String,
265 pub squash_option: String,
267 pub squash_commit_template: String,
269 pub push_access_level: i64,
271 pub merge_access_level: i64,
273}
274
275impl Default for Gitlab {
276 fn default() -> Self {
277 Self {
278 merge_method: "ff".into(),
279 squash_option: "always".into(),
280 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
281 .trim_end_matches('\n')
282 .to_owned(),
283 push_access_level: 0,
284 merge_access_level: 40,
285 }
286 }
287}
288
289pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
294 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
295 Ok(text) => text,
296 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
297 Err(error) => return Err(error.into()),
298 };
299 parse(&text).map(Some)
300}
301
302fn parse(text: &str) -> Result<Config, RkError> {
303 let raw: toml::Value =
304 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
305 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
306 return Err(invalid(format!("schema_version must be {SCHEMA_VERSION}")));
307 }
308 let config: Config = toml::from_str(text).map_err(|error: toml::de::Error| {
309 let mut message = error.to_string();
310 if let Some(rest) = error.message().strip_prefix("unknown field `") {
311 let names: Vec<_> = rest.split('`').collect();
312 if let Some(unknown) = names.first() {
313 if let Some(nearest) = names
314 .iter()
315 .skip(2)
316 .step_by(2)
317 .min_by_key(|name| distance(unknown, name))
318 {
319 let _ = write!(message, "; nearest known key: {nearest}");
320 }
321 }
322 }
323 invalid(message)
324 })?;
325 if !config.project.forge.is_empty()
326 && crate::detect::Forge::parse(&config.project.forge).is_none()
327 {
328 return Err(invalid("project.forge must be github or gitlab"));
329 }
330 if !config.project.tech.is_empty()
331 && (config.project.tech.starts_with('_')
332 || crate::embedded::SNIPPETS
333 .get_dir(&config.project.tech)
334 .is_none())
335 {
336 return Err(invalid(
337 "project.tech must name a supported payload binding",
338 ));
339 }
340 exclusions(&config.setup.excluded_steps)?;
341 floors::check(&config)?;
342 Ok(config)
343}
344
345fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
352 for (name, reason) in excluded {
353 if crate::setup::steps::spec(name).is_none() {
354 let nearest = crate::setup::steps::STEPS
355 .iter()
356 .min_by_key(|step| distance(name, step.name))
357 .map_or("", |step| step.name);
358 return Err(invalid(format!(
359 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
360 )));
361 }
362 if reason.trim().is_empty() {
363 return Err(invalid(format!(
364 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
365 )));
366 }
367 }
368 Ok(())
369}
370
371pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
372 RkError::refusal(
373 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
374 .action(format!("edit {CONFIG_PATH} and retry"))
375 .target_state("nothing was written"),
376 )
377}
378
379fn distance(left: &str, right: &str) -> usize {
380 let mut row: Vec<_> = (0..=right.chars().count()).collect();
381 for (i, a) in left.chars().enumerate() {
382 let mut previous = row[0];
383 row[0] = i + 1;
384 for (j, b) in right.chars().enumerate() {
385 let old = row[j + 1];
386 row[j + 1] = (previous + usize::from(a != b))
387 .min(row[j] + 1)
388 .min(old + 1);
389 previous = old;
390 }
391 }
392 row.last().copied().unwrap_or(0)
393}
394
395pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
400 let bytes = render(config)?;
401 parse(&String::from_utf8_lossy(&bytes))?;
402 crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
403 Ok(())
404}
405
406fn array(values: &[String]) -> toml_edit::Value {
407 toml_edit::Value::Array(values.iter().collect())
408}
409
410fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
415 let mut table = toml_edit::InlineTable::new();
416 for (key, value) in values {
417 table.insert(key, value.clone().into());
418 }
419 toml_edit::Value::InlineTable(table)
420}
421
422#[allow(clippy::too_many_lines)]
423fn render(config: &Config) -> Result<Vec<u8>, RkError> {
424 let trunk = config
428 .project
429 .trunk
430 .clone()
431 .ok_or_else(|| invalid("project.trunk is unresolved"))?;
432 let mut fields: Vec<(&str, toml_edit::Value)> = vec![
433 ("RK_CONFIG_SCHEMA_VERSION", config.schema_version.into()),
434 ("RK_CONFIG_PROJECT_REPO", config.project.repo.clone().into()),
435 (
436 "RK_CONFIG_PROJECT_FORGE",
437 config.project.forge.clone().into(),
438 ),
439 ("RK_CONFIG_PROJECT_TECH", config.project.tech.clone().into()),
440 ("RK_CONFIG_PROJECT_TRUNK", trunk.clone().into()),
441 (
442 "RK_CONFIG_LANDING_WORKFLOW",
443 config
444 .landing
445 .workflow
446 .ok_or_else(|| invalid("landing.workflow is unresolved"))?
447 .as_str()
448 .into(),
449 ),
450 (
451 "RK_CONFIG_LANDING_STYLE",
452 config
453 .landing
454 .style
455 .ok_or_else(|| invalid("landing.style is unresolved"))?
456 .as_str()
457 .into(),
458 ),
459 (
460 "RK_CONFIG_LANDING_NIX",
461 config
462 .landing
463 .nix
464 .ok_or_else(|| invalid("landing.nix is unresolved"))?
465 .into(),
466 ),
467 (
468 "RK_CONFIG_SECURITY_ADVISORIES",
469 config.security.advisories.clone().into(),
470 ),
471 (
472 "RK_CONFIG_SECURITY_CONTACT",
473 config.security.contact.clone().into(),
474 ),
475 (
476 "RK_CONFIG_SECURITY_RESPONSE",
477 config.security.response.clone().into(),
478 ),
479 (
480 "RK_CONFIG_SETUP_REQUIRED_CHECK",
481 config.setup.required_check.clone().into(),
482 ),
483 (
484 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
485 array(&config.setup.retired_branches),
486 ),
487 (
488 "RK_CONFIG_SETUP_LINE_PREFIX",
489 config
490 .setup
491 .line_prefix
492 .clone()
493 .ok_or_else(|| invalid("setup.line_prefix is unresolved"))?
494 .into(),
495 ),
496 (
497 "RK_CONFIG_SETUP_RELEASE_LINES",
498 config.setup.release_lines.into(),
499 ),
500 (
501 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
502 inline(&config.setup.excluded_steps),
503 ),
504 (
505 "RK_CONFIG_SETUP_BOT_APP_ID",
506 config.setup.bot.app_id.clone().into(),
507 ),
508 ];
509 fields.extend(protection_fields(&config.protection, trunk.as_str()));
510 let template = crate::embedded::BLOCKS
511 .get_file("target-config.toml.in")
512 .and_then(include_dir::File::contents_utf8)
513 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
514 let mut bytes = Vec::new();
517 for line in template.split_inclusive('\n') {
518 if let Some((token, value)) = fields.iter().find(|(token, _)| line.contains(token)) {
519 bytes.extend(crate::landing::substitute(
520 line.as_bytes(),
521 token.as_bytes(),
522 value.to_string().as_bytes(),
523 ));
524 } else {
525 bytes.extend_from_slice(line.as_bytes());
526 }
527 }
528 Ok(bytes)
529}
530
531fn protection_fields(
532 protection: &Protection,
533 trunk: &str,
534) -> Vec<(&'static str, toml_edit::Value)> {
535 vec![
536 (
537 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
538 protection.trunk_ruleset(trunk).into(),
539 ),
540 (
541 "RK_CONFIG_PROTECTION_TAG_RULESET",
542 protection.tag_ruleset.clone().into(),
543 ),
544 (
545 "RK_CONFIG_PROTECTION_LINES_RULESET",
546 protection.lines_ruleset.clone().into(),
547 ),
548 (
549 "RK_CONFIG_PROTECTION_TITLE_CHECK",
550 protection.title_check.clone().into(),
551 ),
552 (
553 "RK_CONFIG_PROTECTION_TAG_PATTERN",
554 protection.tag_pattern.clone().into(),
555 ),
556 (
557 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
558 array(&protection.bypass_actors),
559 ),
560 (
561 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
562 array(&protection.allowed_merge_methods),
563 ),
564 (
565 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
566 protection.strict_required_status_checks.into(),
567 ),
568 (
569 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
570 array(&protection.owned_trunk_rules),
571 ),
572 (
573 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
574 protection.required_approving_review_count.into(),
575 ),
576 (
577 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
578 protection.dismiss_stale_reviews_on_push.into(),
579 ),
580 (
581 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
582 protection.require_code_owner_review.into(),
583 ),
584 (
585 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
586 protection.require_last_push_approval.into(),
587 ),
588 (
589 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
590 protection.github.squash_title_source.clone().into(),
591 ),
592 (
593 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
594 protection.github.squash_body_source.clone().into(),
595 ),
596 (
597 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
598 protection.gitlab.merge_method.clone().into(),
599 ),
600 (
601 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
602 protection.gitlab.squash_option.clone().into(),
603 ),
604 (
605 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
606 protection.gitlab.squash_commit_template.clone().into(),
607 ),
608 (
609 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
610 protection.gitlab.push_access_level.into(),
611 ),
612 (
613 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
614 protection.gitlab.merge_access_level.into(),
615 ),
616 ]
617}
618
619pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
624 let path = target.join(CONFIG_PATH);
625 let text = std::fs::read_to_string(&path)?;
626 let next = rewrite_text(&text, key, value)?;
627 crate::atomic::write(&path, next.as_bytes())?;
628 Ok(())
629}
630
631fn rewrite_text(text: &str, key: &str, mut value: toml_edit::Value) -> Result<String, RkError> {
632 if ![
633 "project.repo",
634 "project.forge",
635 "project.tech",
636 "project.trunk",
637 "landing.workflow",
638 "landing.style",
639 "landing.nix",
640 "setup.line_prefix",
641 ]
642 .contains(&key)
643 {
644 return Err(invalid(format!("{key} is not a landing parameter")));
645 }
646 parse(text)?;
647 let mut document = text
648 .parse::<toml_edit::DocumentMut>()
649 .map_err(|error| invalid(error.to_string()))?;
650 let mut item = document.as_item_mut();
651 for segment in key.split('.') {
652 item = &mut item[segment];
653 }
654 if let Some(old) = item.as_value() {
655 if old
656 .as_str()
657 .zip(value.as_str())
658 .is_some_and(|(old, new)| old == new)
659 || old
660 .as_bool()
661 .zip(value.as_bool())
662 .is_some_and(|(old, new)| old == new)
663 {
664 return Ok(text.to_owned());
665 }
666 *value.decor_mut() = old.decor().clone();
667 }
668 *item = toml_edit::Item::Value(value);
669 let next = document.to_string();
670 parse(&next)?;
671 Ok(next)
672}
673
674#[derive(Debug, serde::Serialize)]
676pub struct Plan {
677 pub action: &'static str,
679 pub changes: Vec<String>,
681 pub content: String,
683}
684
685impl Plan {
686 pub fn new(
691 target: &Path,
692 params: &crate::landing::Params,
693 existing: Option<&Config>,
694 record: Option<&crate::landing::manifest::Manifest>,
695 ) -> Result<Self, RkError> {
696 let mut resolved = existing.cloned().unwrap_or_default();
697 resolved.project.tech = params.tech().into();
698 resolved.project.forge = params.forge().into();
699 resolved.project.repo = params.repo().into();
700 resolved.landing = Landing {
701 workflow: Some(params.workflow()),
702 style: params.style(),
703 nix: Some(params.nix()),
704 };
705 resolved.project.trunk = Some(params.trunk().to_owned());
706 resolved.setup.line_prefix = Some(params.line_prefix().to_owned());
707 let content = if existing.is_some() {
708 let mut text = std::fs::read_to_string(target.join(CONFIG_PATH))?;
709 for (key, value) in parameter_values(&resolved) {
710 text = rewrite_text(&text, key, value)?;
711 }
712 text
713 } else {
714 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
715 };
716 parse(&content)?;
717 Ok(Self {
718 action: if existing.is_some() {
719 "updated"
720 } else {
721 "added"
722 },
723 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
724 content,
725 })
726 }
727
728 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
733 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
734 Ok(())
735 }
736}
737
738fn parameter_values(config: &Config) -> Vec<(&'static str, toml_edit::Value)> {
739 let mut values = Vec::new();
740 for (key, value) in [
741 ("project.repo", &config.project.repo),
742 ("project.forge", &config.project.forge),
743 ("project.tech", &config.project.tech),
744 ] {
745 if !value.is_empty() {
746 values.push((key, value.clone().into()));
747 }
748 }
749 if let Some(value) = config.landing.workflow {
750 values.push(("landing.workflow", value.as_str().into()));
751 }
752 if let Some(value) = config.landing.style {
753 values.push(("landing.style", value.as_str().into()));
754 }
755 if let Some(value) = config.landing.nix {
756 values.push(("landing.nix", value.into()));
757 }
758 if let Some(value) = config.project.trunk.clone() {
759 values.push(("project.trunk", value.into()));
760 }
761 if let Some(value) = config.setup.line_prefix.clone() {
762 values.push(("setup.line_prefix", value.into()));
763 }
764 values
765}
766
767#[must_use]
769pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
770 let mut recorded = Config::default();
771 recorded.project.repo.clone_from(&record.parameters.repo);
772 recorded.project.forge.clone_from(&record.forge);
773 recorded.project.tech.clone_from(&record.tech);
774 recorded.landing = Landing {
775 workflow: Some(record.parameters.workflow),
776 style: record.parameters.style,
777 nix: Some(record.parameters.nix),
778 };
779 recorded.project.trunk = Some(record.parameters.trunk.clone());
780 recorded.setup.line_prefix = Some(record.parameters.line_prefix.clone());
781 let baseline = parameter_values(&recorded);
782 parameter_values(config)
783 .into_iter()
784 .filter(|(key, value)| {
785 !baseline
786 .iter()
787 .any(|(other, old)| key == other && value.to_string() == old.to_string())
788 })
789 .map(|(key, _)| key.to_owned())
790 .collect()
791}
792
793pub fn trunk_of(target: &Path) -> Result<String, RkError> {
798 Ok(load(target)?
799 .and_then(|config| config.project.trunk)
800 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
801}
802
803pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
808 Ok(load(target)?
809 .and_then(|config| config.setup.line_prefix)
810 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
811}
812
813#[cfg(test)]
814mod tests {
815 #![allow(clippy::expect_used)]
816
817 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
818 use crate::landing::{Style, Workflow};
819
820 #[test]
821 fn an_omitted_landing_key_is_distinguishable_from_an_explicit_default() {
822 let omitted = parse("schema_version = 1\n").expect("omitted answers parse");
823 let explicit = parse(
824 "schema_version = 1\n[landing]\nworkflow = 'worktree'\nstyle = 'trunk'\nnix = false\n",
825 )
826 .expect("explicit defaults parse");
827 assert_eq!(omitted.landing, super::Landing::default());
828 assert_eq!(explicit.landing.workflow, Some(Workflow::Worktree));
829 assert_eq!(explicit.landing.style, Some(Style::Trunk));
830 assert_eq!(explicit.landing.nix, Some(false));
831 assert_ne!(omitted, explicit);
832 }
833
834 #[test]
838 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
839 let dir = tempfile::tempdir().expect("a tempdir");
840 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
841 std::fs::write(
842 dir.path().join(CONFIG_PATH),
843 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
844 )
845 .expect("the config writes");
846 let held = load(dir.path())
847 .expect("the config reads")
848 .expect("it is present");
849 assert_eq!(held.setup.bot.app_id, "123");
850 assert_eq!(
851 held.setup.bot.installation_id,
852 Some(0),
853 "the key parses; nothing reads it"
854 );
855 }
856
857 #[test]
858 fn the_landed_config_template_round_trips() {
859 let dir = tempfile::tempdir().expect("a target exists");
860 let mut config = Config::default();
861 config.project.repo = "acme/nested/widget".into();
862 config.project.forge = "gitlab".into();
863 config.project.tech = "bash".into();
864 config.project.trunk = Some("main".into());
865 config.landing.workflow = Some(Workflow::Branches);
866 config.landing.style = Some(Style::Lines);
867 config.landing.nix = Some(true);
868 config.security.advisories = "acme/private".into();
869 config.security.contact = "A \"quoted\" contact\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
870 config.security.response = "90d".into();
871 config.setup.required_check = "build / test".into();
872 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
873 config.setup.line_prefix = Some("stable/".into());
874 config.setup.release_lines = true;
875 config.setup.excluded_steps = [
876 (
877 "package-check".to_owned(),
878 "nothing is published".to_owned(),
879 ),
880 (
881 "protect-trunk".to_owned(),
882 "this project merges \"locally\"".to_owned(),
883 ),
884 ]
885 .into_iter()
886 .collect();
887 config.setup.bot.app_id = "123".into();
888 config.protection.trunk_ruleset = Some("primary".into());
889 config.protection.tag_ruleset = "versions".into();
890 config.protection.lines_ruleset = "maintenance".into();
891 config.protection.title_check = "intent".into();
892 config.protection.tag_pattern = "refs/tags/*".into();
893 config
894 .protection
895 .owned_trunk_rules
896 .push("required_signatures".into());
897 config.protection.required_approving_review_count = 2;
898 config.protection.dismiss_stale_reviews_on_push = true;
899 config.protection.require_code_owner_review = true;
900 config.protection.require_last_push_approval = true;
901 config.protection.gitlab.squash_commit_template =
902 "%{title}\n\nContext: %{description}".into();
903 config.protection.gitlab.merge_access_level = 40;
904 let defaults = Config {
905 landing: super::Landing {
906 workflow: Some(Workflow::Worktree),
907 style: Some(Style::Trunk),
908 nix: Some(false),
909 },
910 project: super::Project {
911 trunk: Some(super::TRUNK_DEFAULT.into()),
912 ..super::Project::default()
913 },
914 setup: super::Setup {
915 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
916 ..super::Setup::default()
917 },
918 protection: super::Protection {
921 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
922 ..super::Protection::default()
923 },
924 ..Config::default()
925 };
926 for expected in [defaults, config] {
927 write(dir.path(), &expected).expect("the template renders");
928 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
929 let text =
930 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
931 assert!(text.contains("# P: project path"));
932 assert!(text.contains("# F: invariant"));
933 }
934 }
935
936 #[test]
937 fn a_config_with_an_unknown_key_refuses_by_name() {
938 for (table, typo, nearest) in [
939 ("", "schemax_version", "schema_version"),
940 ("project", "trunkx", "trunk"),
941 ("landing", "stile", "style"),
942 ("security", "contactx", "contact"),
943 ("setup", "required_checkx", "required_check"),
944 ("setup.bot", "app_i", "app_id"),
945 ("protection", "trunk_rulesett", "trunk_ruleset"),
946 (
947 "protection.github",
948 "squash_body_sourcex",
949 "squash_body_source",
950 ),
951 ("protection.gitlab", "squash_optionx", "squash_option"),
952 ] {
953 let header = if table.is_empty() {
954 String::new()
955 } else {
956 format!("[{table}]\n")
957 };
958 let text = format!("schema_version = 1\n{header}{typo} = 'value'\n");
959 let error = parse(&text).expect_err("unknown keys refuse").to_string();
960 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
961 assert!(error.contains(expected), "{error}");
962 }
963 }
964 }
965
966 #[test]
970 fn an_exclusion_names_a_real_step_and_states_why() {
971 for (text, expected) in [
972 (
973 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
974 vec!["protect-trunkk", "nearest known step: protect-trunk"],
975 ),
976 (
977 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
978 vec!["protect-trunk", "no reason"],
979 ),
980 ] {
981 let error = parse(&format!("schema_version = 1\n{text}"))
982 .expect_err("the exclusion refuses")
983 .to_string();
984 for want in expected {
985 assert!(error.contains(want), "{error}");
986 }
987 }
988 let held = parse(
989 "schema_version = 1\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
990 )
991 .expect("a named step with a reason parses");
992 assert_eq!(
993 held.setup
994 .excluded_steps
995 .get("protect-trunk")
996 .map(String::as_str),
997 Some("we merge locally")
998 );
999 }
1000
1001 #[test]
1005 fn an_exclusion_does_not_lift_a_floor() {
1006 let error = parse(
1007 "schema_version = 1\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
1008 )
1009 .expect_err("the floor binds an excluded step's keys too")
1010 .to_string();
1011 assert!(
1012 error.contains("protection.allowed_merge_methods"),
1013 "{error}"
1014 );
1015 }
1016
1017 #[test]
1018 fn a_config_at_an_unknown_schema_refuses() {
1019 for text in ["schema_version = 999", "schema_version = '1'", ""] {
1020 let error = parse(text)
1021 .expect_err("a schema must be declared and known")
1022 .to_string();
1023 assert!(
1024 error.contains(CONFIG_PATH) && error.contains("schema_version"),
1025 "{error}"
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn an_unparsable_config_refuses_naming_the_position() {
1032 let error = parse("schema_version = 1\n[project\n")
1033 .expect_err("bad TOML refuses")
1034 .to_string();
1035 for expected in [CONFIG_PATH, "line 2", "column"] {
1036 assert!(error.contains(expected), "{error}");
1037 }
1038 }
1039
1040 #[test]
1041 fn an_absent_config_reads_as_none() {
1042 let dir = tempfile::tempdir().expect("a target exists");
1043 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
1044 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
1045 }
1046
1047 #[test]
1048 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
1049 let dir = tempfile::tempdir().expect("a target exists");
1050 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1051 std::fs::write(
1052 dir.path().join(CONFIG_PATH),
1053 "schema_version = 1\n[protection]\nstrict_required_status_checks = false\n",
1054 )
1055 .expect("a config exists");
1056 let error =
1057 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
1058 assert_eq!(error.exit_code(), 73);
1059 assert!(
1060 error
1061 .to_string()
1062 .contains("protection.strict_required_status_checks")
1063 );
1064 }
1065
1066 #[test]
1067 fn rewrite_key_preserves_comments() {
1068 let dir = tempfile::tempdir().expect("a target exists");
1069 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1070 let original = "# Project answers\nschema_version = 1\n\n[security] # first table stays first\ncontact = 'team' # keep me\n\n[landing]\n# Our release choice\nstyle = 'trunk' # keep this reason\nworkflow = 'branches'\n";
1071 let path = dir.path().join(CONFIG_PATH);
1072 std::fs::write(&path, original).expect("a config exists");
1073 rewrite_key(dir.path(), "landing.style", "lines".into()).expect("the style writes back");
1074 let text = std::fs::read_to_string(&path).expect("the text reads");
1075 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
1076 assert_eq!(
1077 load(dir.path())
1078 .expect("the config reads")
1079 .expect("present")
1080 .landing
1081 .style,
1082 Some(Style::Lines)
1083 );
1084 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
1085 .expect("an omitted table can be added");
1086 assert_eq!(
1087 load(dir.path())
1088 .expect("reads")
1089 .expect("present")
1090 .project
1091 .repo,
1092 "acme/widget"
1093 );
1094 let before = std::fs::read(&path).expect("the bytes read");
1095 for (key, value) in [("security.contact", "other"), ("landing.style", "unknown")] {
1096 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
1097 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
1098 }
1099 }
1100}