1pub mod floors;
8pub mod migrate;
9
10use std::collections::BTreeMap;
11use std::fmt::Write as _;
12use std::path::Path;
13
14use crate::diagnostic::{Diagnostic, Reason};
15use crate::error::RkError;
16use crate::landing::{CheckoutMode, Integration, Style};
17use crate::profile::ReleaseMode;
18use serde::Deserialize;
19
20pub const CONFIG_PATH: &str = ".release-kit/config.toml";
22pub const SCHEMA_VERSION: i64 = 2;
25
26#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
28#[serde(deny_unknown_fields, default)]
29pub struct Config {
30 pub schema_version: i64,
32 pub project: Project,
34 pub profile: Profile,
36 pub git: Git,
38 pub capabilities: Capabilities,
40 pub security: Security,
42 pub setup: Setup,
44 pub protection: Protection,
46}
47
48impl Default for Config {
49 fn default() -> Self {
50 Self {
51 schema_version: SCHEMA_VERSION,
52 project: Project::default(),
53 profile: Profile::default(),
54 git: Git::default(),
55 capabilities: Capabilities::default(),
56 security: Security::default(),
57 setup: Setup::default(),
58 protection: Protection::default(),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
65#[serde(deny_unknown_fields, default)]
66pub struct Project {
67 pub repo: String,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
74#[serde(deny_unknown_fields, default)]
75pub struct Profile {
76 pub technologies: Option<Vec<String>>,
78 pub forge: Option<String>,
81 pub release: Release,
83}
84
85#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
87#[serde(deny_unknown_fields, default)]
88pub struct Release {
89 pub mode: Option<ReleaseMode>,
92 pub driver: Option<String>,
95 pub style: Option<Style>,
97 pub line_prefix: Option<String>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
103#[serde(deny_unknown_fields, default)]
104pub struct Git {
105 pub trunk: Option<String>,
109 pub checkout_mode: Option<CheckoutMode>,
111 pub integration: Option<Integration>,
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
118#[serde(deny_unknown_fields, default)]
119pub struct Capabilities {
120 pub nix_packaging: Option<bool>,
122 pub reporting_policy: Option<bool>,
124 pub scorecard: Option<bool>,
126 pub code_scanning: Option<String>,
130}
131
132pub const TRUNK_DEFAULT: &str = "master";
134
135pub const LINE_PREFIX_DEFAULT: &str = "release/";
137
138#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
140#[serde(deny_unknown_fields, default)]
141pub struct Security {
142 pub advisories: String,
144 pub contact: Option<String>,
149 pub response: Option<String>,
152}
153
154pub const RESPONSE_DEFAULT: &str = "best-effort";
157
158pub const LOCAL_GITHUB_BYPASS: &str = "repository-admin";
164
165pub fn canonical_contact(raw: &str) -> Result<String, String> {
175 let trimmed = raw.trim();
176 if trimmed.chars().any(char::is_control) {
177 return Err(format!(
178 "security.contact carries a control character; it is one line naming an address, a URL, a person, or a team, and empty selects the forge's own wording, found {trimmed:?}"
179 ));
180 }
181 Ok(trimmed.to_owned())
182}
183
184pub fn canonical_response(raw: &str) -> Result<String, String> {
196 let trimmed = raw.trim();
197 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
198 return Ok(RESPONSE_DEFAULT.to_owned());
199 }
200 let refusal = || {
201 format!(
202 "security.response must be one of: best-effort, 1 day, <n> days, 1 business day, <n> business days, where n is a whole number above one; found {trimmed:?}"
203 )
204 };
205 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
206 let plural = match unit {
207 "day" | "business day" => false,
208 "days" | "business days" => true,
209 _ => return Err(refusal()),
210 };
211 let number: u32 = count.parse().map_err(|_| refusal())?;
212 if count != number.to_string() || (number > 1) != plural {
215 return Err(refusal());
216 }
217 Ok(trimmed.to_owned())
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
222#[serde(deny_unknown_fields, default)]
223pub struct Setup {
224 pub required_check: String,
227 pub required_workflow: String,
231 pub retired_branches: Vec<String>,
233 pub release_lines: bool,
235 pub excluded_steps: BTreeMap<String, String>,
240 pub bot: Bot,
242}
243
244impl Default for Setup {
245 fn default() -> Self {
246 Self {
247 required_check: String::new(),
248 required_workflow: String::new(),
249 retired_branches: vec!["main".into(), "develop".into()],
250 release_lines: false,
251 excluded_steps: BTreeMap::new(),
252 bot: Bot::default(),
253 }
254 }
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
264#[serde(deny_unknown_fields, default)]
265pub struct Bot {
266 pub app_id: String,
268 #[serde(default, skip_serializing)]
273 pub installation_id: Option<i64>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
278#[serde(deny_unknown_fields, default)]
279#[allow(
280 clippy::struct_excessive_bools,
281 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
282)]
283pub struct Protection {
284 pub trunk_ruleset: Option<String>,
288 pub safety_ruleset: Option<String>,
293 pub tag_ruleset: String,
295 pub lines_ruleset: String,
297 pub title_check: String,
299 pub tag_pattern: String,
301 pub bypass_actors: Vec<String>,
304 pub allowed_merge_methods: Vec<String>,
306 pub strict_required_status_checks: bool,
308 pub owned_trunk_rules: Vec<String>,
312 pub required_approving_review_count: i64,
314 pub dismiss_stale_reviews_on_push: bool,
316 pub require_code_owner_review: bool,
318 pub require_last_push_approval: bool,
320 pub github: Github,
322 pub gitlab: Gitlab,
324}
325
326impl Default for Protection {
327 fn default() -> Self {
328 Self {
329 trunk_ruleset: None,
330 safety_ruleset: None,
331 tag_ruleset: "release-tags".into(),
332 lines_ruleset: "release-lines".into(),
333 title_check: "pr-title".into(),
334 tag_pattern: "refs/tags/v*".into(),
335 bypass_actors: Vec::new(),
336 allowed_merge_methods: vec!["squash".into()],
337 strict_required_status_checks: true,
338 owned_trunk_rules: vec![
339 "deletion".into(),
340 "non_fast_forward".into(),
341 "pull_request".into(),
342 "required_status_checks".into(),
343 ],
344 required_approving_review_count: 0,
345 dismiss_stale_reviews_on_push: false,
346 require_code_owner_review: false,
347 require_last_push_approval: false,
348 github: Github::default(),
349 gitlab: Gitlab::default(),
350 }
351 }
352}
353
354impl Protection {
355 #[must_use]
358 pub fn trunk_ruleset(&self, trunk: &str) -> String {
359 self.trunk_ruleset
360 .clone()
361 .unwrap_or_else(|| format!("{trunk}-protection"))
362 }
363
364 #[must_use]
371 pub fn safety_ruleset(&self, trunk: &str) -> String {
372 self.safety_ruleset
373 .clone()
374 .unwrap_or_else(|| format!("{trunk}-safety"))
375 }
376}
377
378pub const SAFETY_RULES: [&str; 2] = ["deletion", "non_fast_forward"];
385
386pub const REQUEST_RULES: [&str; 2] = ["pull_request", "required_status_checks"];
389
390#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
392#[serde(deny_unknown_fields, default)]
393pub struct Github {
394 pub squash_title_source: String,
396 pub squash_body_source: String,
398}
399
400impl Default for Github {
401 fn default() -> Self {
402 Self {
403 squash_title_source: "PR_TITLE".into(),
404 squash_body_source: "PR_BODY".into(),
405 }
406 }
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
411#[serde(deny_unknown_fields, default)]
412pub struct Gitlab {
413 pub merge_method: String,
415 pub squash_option: String,
417 pub squash_commit_template: String,
419 pub push_access_level: i64,
424 pub merge_access_level: i64,
426}
427
428impl Default for Gitlab {
429 fn default() -> Self {
430 Self {
431 merge_method: "ff".into(),
432 squash_option: "always".into(),
433 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
434 .trim_end_matches('\n')
435 .to_owned(),
436 push_access_level: 0,
437 merge_access_level: 40,
438 }
439 }
440}
441
442pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
447 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
448 Ok(text) => text,
449 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
450 Err(error) => return Err(error.into()),
451 };
452 parse(&text).map(Some)
453}
454
455fn current_text(text: &str) -> Result<String, RkError> {
458 let raw: toml::Value =
459 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
460 match raw.get("schema_version").and_then(toml::Value::as_integer) {
461 Some(1) => migrate::to_schema_2(text),
462 _ => Ok(text.to_owned()),
463 }
464}
465
466fn parse(text: &str) -> Result<Config, RkError> {
467 let text = current_text(text)?;
468 let raw: toml::Value =
469 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
470 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
471 return Err(invalid(format!(
472 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
473 )));
474 }
475 if let Some(mode) = raw
479 .get("profile")
480 .and_then(|profile| profile.get("release"))
481 .and_then(|release| release.get("mode"))
482 {
483 let named = mode.as_str().ok_or_else(|| {
484 invalid("profile.release.mode must be a string: automatic, external, or none")
485 })?;
486 crate::profile::ReleaseMode::parse(named)
487 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
488 }
489 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
490 let mut message = error.to_string();
491 if let Some(rest) = error.message().strip_prefix("unknown field `") {
492 let names: Vec<_> = rest.split('`').collect();
493 if let Some(unknown) = names.first()
494 && let Some(nearest) = names
495 .iter()
496 .skip(2)
497 .step_by(2)
498 .min_by_key(|name| distance(unknown, name))
499 {
500 let _ = write!(message, "; nearest known key: {nearest}");
501 }
502 }
503 invalid(message)
504 })?;
505 if let Some(technologies) = &config.profile.technologies {
506 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
507 }
508 if let Some(forge) = &config.profile.forge
509 && !forge.is_empty()
510 {
511 crate::profile::canonical_category(forge)
512 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
513 }
514 if let Some(driver) = &config.profile.release.driver {
515 crate::profile::canonical_category(driver)
516 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
517 }
518 if matches!(
519 config.profile.release.mode,
520 Some(ReleaseMode::External | ReleaseMode::None)
521 ) {
522 let mode = config
523 .profile
524 .release
525 .mode
526 .map_or("none", ReleaseMode::as_str);
527 for (key, present) in [
528 (
529 "profile.release.driver",
530 config.profile.release.driver.is_some(),
531 ),
532 (
533 "profile.release.style",
534 config.profile.release.style.is_some(),
535 ),
536 (
537 "profile.release.line_prefix",
538 config.profile.release.line_prefix.is_some(),
539 ),
540 ] {
541 if present {
542 return Err(invalid(format!(
543 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
544 )));
545 }
546 }
547 }
548 if let Some(contact) = &config.security.contact {
549 canonical_contact(contact).map_err(invalid)?;
550 }
551 if let Some(response) = &config.security.response {
552 canonical_response(response).map_err(invalid)?;
553 }
554 exclusions(&config.setup.excluded_steps)?;
555 floors::check(&config)?;
556 Ok(config)
557}
558
559fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
566 for (name, reason) in excluded {
567 if crate::setup::steps::spec(name).is_none() {
568 let nearest = crate::setup::steps::STEPS
569 .iter()
570 .min_by_key(|step| distance(name, step.name))
571 .map_or("", |step| step.name);
572 return Err(invalid(format!(
573 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
574 )));
575 }
576 if reason.trim().is_empty() {
577 return Err(invalid(format!(
578 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
579 )));
580 }
581 }
582 Ok(())
583}
584
585pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
586 RkError::refusal(
587 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
588 .action(format!("edit {CONFIG_PATH} and retry"))
589 .target_state("nothing was written"),
590 )
591}
592
593fn distance(left: &str, right: &str) -> usize {
594 let mut row: Vec<_> = (0..=right.chars().count()).collect();
595 for (i, a) in left.chars().enumerate() {
596 let mut previous = row[0];
597 row[0] = i + 1;
598 for (j, b) in right.chars().enumerate() {
599 let old = row[j + 1];
600 row[j + 1] = (previous + usize::from(a != b))
601 .min(row[j] + 1)
602 .min(old + 1);
603 previous = old;
604 }
605 }
606 row.last().copied().unwrap_or(0)
607}
608
609pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
614 let bytes = render(config)?;
615 parse(&String::from_utf8_lossy(&bytes))?;
616 crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
617 Ok(())
618}
619
620fn array(values: &[String]) -> toml_edit::Value {
621 toml_edit::Value::Array(values.iter().collect())
622}
623
624fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
629 let mut table = toml_edit::InlineTable::new();
630 for (key, value) in values {
631 table.insert(key, value.clone().into());
632 }
633 toml_edit::Value::InlineTable(table)
634}
635
636#[allow(
641 clippy::too_many_lines,
642 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
643)]
644fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
645 let trunk = config
649 .git
650 .trunk
651 .clone()
652 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
653 let technologies = config
654 .profile
655 .technologies
656 .clone()
657 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
658 let forge = config
659 .profile
660 .forge
661 .clone()
662 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
663 let mode = config
664 .profile
665 .release
666 .mode
667 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
668 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
669 (
670 "RK_CONFIG_SCHEMA_VERSION",
671 Some(config.schema_version.into()),
672 ),
673 (
674 "RK_CONFIG_PROJECT_REPO",
675 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
676 ),
677 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
678 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
679 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
680 (
681 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
682 config
683 .profile
684 .release
685 .driver
686 .clone()
687 .map(toml_edit::Value::from),
688 ),
689 (
690 "RK_CONFIG_PROFILE_RELEASE_STYLE",
691 config
692 .profile
693 .release
694 .style
695 .map(|style| style.as_str().into()),
696 ),
697 (
698 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
699 config
700 .profile
701 .release
702 .line_prefix
703 .clone()
704 .map(toml_edit::Value::from),
705 ),
706 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
707 (
708 "RK_CONFIG_GIT_CHECKOUT_MODE",
709 Some(
710 config
711 .git
712 .checkout_mode
713 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
714 .as_str()
715 .into(),
716 ),
717 ),
718 (
719 "RK_CONFIG_GIT_INTEGRATION",
720 Some(
721 config
722 .git
723 .integration
724 .ok_or_else(|| invalid("git.integration is unresolved"))?
725 .as_str()
726 .into(),
727 ),
728 ),
729 (
730 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
731 Some(
732 config
733 .capabilities
734 .nix_packaging
735 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
736 .into(),
737 ),
738 ),
739 (
740 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
741 Some(
742 config
743 .capabilities
744 .reporting_policy
745 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
746 .into(),
747 ),
748 ),
749 (
750 "RK_CONFIG_CAPABILITIES_SCORECARD",
751 Some(
752 config
753 .capabilities
754 .scorecard
755 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
756 .into(),
757 ),
758 ),
759 (
760 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
761 Some(
762 config
763 .capabilities
764 .code_scanning
765 .clone()
766 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
767 .into(),
768 ),
769 ),
770 (
771 "RK_CONFIG_SECURITY_ADVISORIES",
772 Some(config.security.advisories.clone().into()),
773 ),
774 (
775 "RK_CONFIG_SECURITY_CONTACT",
776 Some(config.security.contact.clone().unwrap_or_default().into()),
777 ),
778 (
779 "RK_CONFIG_SECURITY_RESPONSE",
780 Some(
781 config
782 .security
783 .response
784 .clone()
785 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
786 .into(),
787 ),
788 ),
789 (
790 "RK_CONFIG_SETUP_REQUIRED_CHECK",
791 Some(config.setup.required_check.clone().into()),
792 ),
793 (
794 "RK_CONFIG_SETUP_REQUIRED_WORKFLOW",
795 Some(config.setup.required_workflow.clone().into()),
796 ),
797 (
798 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
799 Some(array(&config.setup.retired_branches)),
800 ),
801 (
802 "RK_CONFIG_SETUP_RELEASE_LINES",
803 Some(config.setup.release_lines.into()),
804 ),
805 (
806 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
807 Some(inline(&config.setup.excluded_steps)),
808 ),
809 (
810 "RK_CONFIG_SETUP_BOT_APP_ID",
811 Some(config.setup.bot.app_id.clone().into()),
812 ),
813 ];
814 fields.extend(
815 protection_fields(&config.protection, trunk.as_str())
816 .into_iter()
817 .map(|(token, value)| (token, Some(value))),
818 );
819 Ok(fields)
820}
821
822fn render(config: &Config) -> Result<Vec<u8>, RkError> {
823 let fields = fields(config)?;
824 let template = crate::embedded::BLOCKS
825 .get_file("target-config.toml.in")
826 .and_then(include_dir::File::contents_utf8)
827 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
828 let lines: Vec<&str> = template.split_inclusive('\n').collect();
834 let dropped: Vec<bool> = lines
835 .iter()
836 .map(|line| {
837 matches!(
838 fields.iter().find(|(token, _)| line.contains(token)),
839 Some((_, None))
840 )
841 })
842 .collect();
843 let mut bytes = Vec::new();
844 let mut at = 0;
845 while at < lines.len() {
846 let line = lines[at];
847 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
848 let mut end = at + 1;
849 let mut keeps = false;
850 while end < lines.len()
851 && !(lines[end].trim_start().starts_with('[')
852 && lines[end].trim_end().ends_with(']'))
853 {
854 keeps |=
855 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
856 end += 1;
857 }
858 if !keeps {
859 while bytes.last() == Some(&b'\n')
861 && bytes.len() >= 2
862 && bytes[bytes.len() - 2] == b'\n'
863 {
864 bytes.pop();
865 }
866 at = end;
867 continue;
868 }
869 }
870 if !dropped[at] {
871 match fields.iter().find(|(token, _)| line.contains(token)) {
872 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
873 line.as_bytes(),
874 token.as_bytes(),
875 value.to_string().as_bytes(),
876 )),
877 Some((_, None)) => {}
878 None => bytes.extend_from_slice(line.as_bytes()),
879 }
880 }
881 at += 1;
882 }
883 Ok(bytes)
884}
885
886fn protection_fields(
887 protection: &Protection,
888 trunk: &str,
889) -> Vec<(&'static str, toml_edit::Value)> {
890 vec![
891 (
892 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
893 protection.trunk_ruleset(trunk).into(),
894 ),
895 (
896 "RK_CONFIG_PROTECTION_SAFETY_RULESET",
897 protection.safety_ruleset(trunk).into(),
898 ),
899 (
900 "RK_CONFIG_PROTECTION_TAG_RULESET",
901 protection.tag_ruleset.clone().into(),
902 ),
903 (
904 "RK_CONFIG_PROTECTION_LINES_RULESET",
905 protection.lines_ruleset.clone().into(),
906 ),
907 (
908 "RK_CONFIG_PROTECTION_TITLE_CHECK",
909 protection.title_check.clone().into(),
910 ),
911 (
912 "RK_CONFIG_PROTECTION_TAG_PATTERN",
913 protection.tag_pattern.clone().into(),
914 ),
915 (
916 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
917 array(&protection.bypass_actors),
918 ),
919 (
920 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
921 array(&protection.allowed_merge_methods),
922 ),
923 (
924 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
925 protection.strict_required_status_checks.into(),
926 ),
927 (
928 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
929 array(&protection.owned_trunk_rules),
930 ),
931 (
932 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
933 protection.required_approving_review_count.into(),
934 ),
935 (
936 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
937 protection.dismiss_stale_reviews_on_push.into(),
938 ),
939 (
940 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
941 protection.require_code_owner_review.into(),
942 ),
943 (
944 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
945 protection.require_last_push_approval.into(),
946 ),
947 (
948 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
949 protection.github.squash_title_source.clone().into(),
950 ),
951 (
952 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
953 protection.github.squash_body_source.clone().into(),
954 ),
955 (
956 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
957 protection.gitlab.merge_method.clone().into(),
958 ),
959 (
960 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
961 protection.gitlab.squash_option.clone().into(),
962 ),
963 (
964 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
965 protection.gitlab.squash_commit_template.clone().into(),
966 ),
967 (
968 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
969 protection.gitlab.push_access_level.into(),
970 ),
971 (
972 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
973 protection.gitlab.merge_access_level.into(),
974 ),
975 ]
976}
977
978const DERIVED_POLICY_KEYS: [&str; 3] = [
988 "protection.bypass_actors",
989 "protection.owned_trunk_rules",
990 "protection.gitlab.push_access_level",
991];
992
993const PARAMETER_KEYS: [&str; 22] = [
995 "project.repo",
996 "profile.technologies",
997 "profile.forge",
998 "profile.release.mode",
999 "profile.release.driver",
1000 "profile.release.style",
1001 "profile.release.line_prefix",
1002 "git.trunk",
1003 "git.checkout_mode",
1004 "git.integration",
1005 "protection.bypass_actors",
1009 "protection.owned_trunk_rules",
1010 "protection.gitlab.push_access_level",
1011 "capabilities.nix_packaging",
1012 "capabilities.reporting_policy",
1013 "capabilities.scorecard",
1014 "capabilities.code_scanning",
1015 "security.contact",
1016 "security.response",
1017 "setup.required_check",
1020 "setup.required_workflow",
1021 "schema_version",
1022];
1023
1024pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
1029 let path = target.join(CONFIG_PATH);
1030 let text = std::fs::read_to_string(&path)?;
1031 let next = rewrite_text(&text, key, Some(value))?;
1032 crate::atomic::write(&path, next.as_bytes())?;
1033 Ok(())
1034}
1035
1036fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
1039 rewrite_all(text, vec![(key, value)])
1040}
1041
1042fn rewrite_all(
1050 text: &str,
1051 values: Vec<(&str, Option<toml_edit::Value>)>,
1052) -> Result<String, RkError> {
1053 for (key, _) in &values {
1054 if !PARAMETER_KEYS.contains(key) {
1055 return Err(invalid(format!("{key} is not a landing parameter")));
1056 }
1057 }
1058 parse(text)?;
1059 let text = current_text(text)?;
1060 let mut document = text
1061 .parse::<toml_edit::DocumentMut>()
1062 .map_err(|error| invalid(error.to_string()))?;
1063 let mut changed = false;
1064 let mut emptied: Vec<&str> = Vec::new();
1067 for (key, value) in values {
1068 let removing = value.is_none();
1069 let applied = apply_key(&mut document, key, value)?;
1070 changed |= applied;
1071 if applied
1072 && removing
1073 && let Some(parent) = key.split('.').next()
1074 {
1075 emptied.push(parent);
1076 }
1077 }
1078 changed |= prune_empty_tables(&mut document, &emptied);
1079 changed |= migrate::refresh_comments(&mut document);
1086 if !changed {
1087 return Ok(text);
1088 }
1089 let next = document.to_string();
1090 parse(&next)?;
1091 Ok(next)
1092}
1093
1094pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1113 let order: Vec<String> = document
1114 .as_table()
1115 .iter()
1116 .map(|(key, _)| key.to_owned())
1117 .collect();
1118 let mut changed = false;
1119 for (index, name) in order.iter().enumerate() {
1120 if !names.contains(&name.as_str())
1121 || !document
1122 .get(name)
1123 .and_then(toml_edit::Item::as_table)
1124 .is_some_and(toml_edit::Table::is_empty)
1125 {
1126 continue;
1127 }
1128 let carried = document
1129 .get(name)
1130 .and_then(toml_edit::Item::as_table)
1131 .and_then(|table| carried_comment(table.decor()));
1132 document.remove(name);
1133 changed = true;
1134 let Some(carried) = carried else { continue };
1135 let next = order
1139 .iter()
1140 .skip(index + 1)
1141 .find(|name| {
1142 document
1143 .get(name)
1144 .and_then(toml_edit::Item::as_table)
1145 .is_some_and(|table| !table.is_implicit())
1146 })
1147 .cloned();
1148 if let Some(next) = next
1149 && let Some(table) = document
1150 .get_mut(&next)
1151 .and_then(toml_edit::Item::as_table_mut)
1152 {
1153 let existing = table
1154 .decor()
1155 .prefix()
1156 .and_then(toml_edit::RawString::as_str)
1157 .unwrap_or_default()
1158 .trim_start_matches('\n')
1159 .to_owned();
1160 table
1161 .decor_mut()
1162 .set_prefix(format!("\n{carried}{existing}"));
1163 } else {
1164 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1165 if !trailing.is_empty() && !trailing.ends_with('\n') {
1166 trailing.push('\n');
1167 }
1168 trailing.push_str(&carried);
1169 document.set_trailing(trailing);
1170 }
1171 }
1172 changed
1173}
1174
1175fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1182 let mut lines = String::new();
1183 for raw in [decor.prefix(), decor.suffix()] {
1184 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1185 continue;
1186 };
1187 for line in text
1188 .lines()
1189 .map(str::trim)
1190 .filter(|line| line.starts_with('#'))
1191 .filter(|line| !is_template_comment(line))
1192 {
1193 lines.push_str(line);
1194 lines.push('\n');
1195 }
1196 }
1197 (!lines.is_empty()).then_some(lines)
1198}
1199
1200fn is_template_comment(line: &str) -> bool {
1209 let rest = line.trim_start_matches('#').trim_start();
1210 ["P:", "N:", "F:"]
1211 .iter()
1212 .any(|marker| rest.starts_with(marker))
1213}
1214
1215pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1223 let first = document
1224 .as_table()
1225 .iter()
1226 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1227 .map(|(name, _)| name.to_owned());
1228 if let Some(first) = first
1229 && let Some(table) = document
1230 .get_mut(&first)
1231 .and_then(toml_edit::Item::as_table_mut)
1232 {
1233 let existing = table
1234 .decor()
1235 .prefix()
1236 .and_then(toml_edit::RawString::as_str)
1237 .unwrap_or_default()
1238 .trim_start_matches('\n')
1239 .to_owned();
1240 table
1241 .decor_mut()
1242 .set_prefix(format!("\n{carried}{existing}"));
1243 return;
1244 }
1245 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1246 if !trailing.is_empty() && !trailing.ends_with('\n') {
1247 trailing.push('\n');
1248 }
1249 trailing.push_str(carried);
1250 document.set_trailing(trailing);
1251}
1252
1253pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1259 let (name, _) = table.get_key_value(key)?;
1260 carried_comment(name.leaf_decor())
1261}
1262
1263pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1265 let Some(mut name) = table.key_mut(key) else {
1266 return;
1267 };
1268 let decor = name.leaf_decor_mut();
1269 let existing = decor
1270 .prefix()
1271 .and_then(toml_edit::RawString::as_str)
1272 .unwrap_or_default()
1273 .trim_start_matches('\n')
1274 .to_owned();
1275 decor.set_prefix(format!("\n{carried}{existing}"));
1276}
1277
1278#[must_use]
1280pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1281 known
1282 .iter()
1283 .min_by_key(|name| distance(unknown, name))
1284 .copied()
1285}
1286
1287pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1290 let carried = carried_comment(table.decor())?;
1291 table.decor_mut().set_prefix("\n");
1292 Some(carried)
1293}
1294
1295pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1306 let carried = table.get_key_value(key).and_then(|(name, item)| {
1307 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1308 if let Some(value) = item.as_value()
1309 && let Some(more) = carried_comment(value.decor())
1310 {
1311 lines.push_str(&more);
1312 }
1313 (!lines.is_empty()).then_some(lines)
1314 });
1315 let removed = table.remove(key).is_some();
1316 if let Some(carried) = carried {
1317 let existing = table
1318 .decor()
1319 .prefix()
1320 .and_then(toml_edit::RawString::as_str)
1321 .unwrap_or_default()
1322 .trim_start_matches('\n')
1323 .to_owned();
1324 table
1325 .decor_mut()
1326 .set_prefix(format!("\n{carried}{existing}"));
1327 }
1328 removed
1329}
1330
1331fn apply_key(
1334 document: &mut toml_edit::DocumentMut,
1335 key: &str,
1336 value: Option<toml_edit::Value>,
1337) -> Result<bool, RkError> {
1338 let segments: Vec<&str> = key.split('.').collect();
1339 let Some((last, parents)) = segments.split_last() else {
1342 return Err(invalid(format!("{key} names no key")));
1343 };
1344 let Some(mut value) = value else {
1345 let mut item = document.as_item_mut();
1346 for segment in parents {
1347 if item.get(segment).is_none() {
1348 return Ok(false);
1349 }
1350 item = &mut item[segment];
1351 }
1352 let removed = item
1353 .as_table_mut()
1354 .is_some_and(|table| take_comments(table, last));
1355 return Ok(removed);
1356 };
1357 let mut item = document.as_item_mut();
1358 for segment in parents {
1359 if item.get(segment).is_none() {
1360 let mut table = toml_edit::Table::new();
1361 table.set_implicit(true);
1362 item[segment] = toml_edit::Item::Table(table);
1363 }
1364 item = &mut item[segment];
1365 }
1366 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1367 if old.to_string().trim() == value.to_string().trim() {
1368 return Ok(false);
1369 }
1370 *value.decor_mut() = old.decor().clone();
1371 } else if let Some(comment) = migrate::template_comment(&segments) {
1372 value.decor_mut().set_suffix(comment);
1378 }
1379 item[last] = toml_edit::Item::Value(value);
1380 Ok(true)
1381}
1382
1383#[derive(Debug, Clone, serde::Serialize)]
1385pub struct Plan {
1386 pub action: &'static str,
1388 pub changes: Vec<String>,
1390 pub content: String,
1392}
1393
1394impl Plan {
1395 pub fn new(
1400 target: &Path,
1401 params: &crate::landing::Params,
1402 existing: Option<&Config>,
1403 record: Option<&crate::landing::manifest::Manifest>,
1404 ) -> Result<Self, RkError> {
1405 let text = existing
1406 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1407 .transpose()?;
1408 Self::compose(text.as_deref(), params, existing, record)
1409 }
1410
1411 pub fn compose(
1418 text: Option<&str>,
1419 params: &crate::landing::Params,
1420 existing: Option<&Config>,
1421 record: Option<&crate::landing::manifest::Manifest>,
1422 ) -> Result<Self, RkError> {
1423 let mut resolved = existing.cloned().unwrap_or_default();
1424 resolved.schema_version = SCHEMA_VERSION;
1425 params.repo().clone_into(&mut resolved.project.repo);
1426 resolved.profile = Profile {
1427 technologies: Some(params.technologies().to_vec()),
1428 forge: Some(params.forge().unwrap_or_default().to_owned()),
1429 release: Release {
1430 mode: Some(params.release_mode()),
1431 driver: params.driver().map(str::to_owned),
1432 style: params.style(),
1433 line_prefix: params.profile().release.line_prefix.clone(),
1434 },
1435 };
1436 resolved.git = Git {
1437 trunk: Some(params.trunk().to_owned()),
1438 checkout_mode: Some(params.checkout_mode()),
1439 integration: Some(params.integration()),
1440 };
1441 resolved.protection = protection_for(
1442 &resolved.protection,
1443 params.integration(),
1444 existing.is_none(),
1445 );
1446 resolved.capabilities = Capabilities {
1447 nix_packaging: Some(params.nix_packaging()),
1448 reporting_policy: Some(params.reporting_policy()),
1449 scorecard: Some(params.scorecard()),
1450 code_scanning: Some(
1451 params
1452 .code_scanning()
1453 .map_or("off", crate::landing::Provider::as_str)
1454 .to_owned(),
1455 ),
1456 };
1457 resolved.security.contact = Some(params.security_contact().to_owned());
1458 resolved.security.response = Some(params.security_response().to_owned());
1459 params
1460 .required_check()
1461 .clone_into(&mut resolved.setup.required_check);
1462 params
1463 .required_workflow()
1464 .clone_into(&mut resolved.setup.required_workflow);
1465 let content = if let Some(text) = text.filter(|_| existing.is_some()) {
1466 rewrite_all(text, parameter_values(&resolved))?
1467 } else {
1468 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
1469 };
1470 parse(&content)?;
1471 Ok(Self {
1472 action: if existing.is_some() {
1473 "updated"
1474 } else {
1475 "added"
1476 },
1477 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1478 content,
1479 })
1480 }
1481
1482 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1487 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
1488 Ok(())
1489 }
1490}
1491
1492fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1494 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1495 "project.repo",
1496 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1497 )];
1498 if let Some(list) = &config.profile.technologies {
1499 values.push(("profile.technologies", Some(array(list))));
1500 }
1501 if let Some(forge) = config.profile.forge.clone() {
1502 values.push(("profile.forge", Some(forge.into())));
1503 }
1504 if let Some(mode) = config.profile.release.mode {
1505 values.push(("profile.release.mode", Some(mode.as_str().into())));
1506 values.push((
1507 "profile.release.driver",
1508 config
1509 .profile
1510 .release
1511 .driver
1512 .clone()
1513 .map(toml_edit::Value::from),
1514 ));
1515 values.push((
1516 "profile.release.style",
1517 config
1518 .profile
1519 .release
1520 .style
1521 .map(|style| style.as_str().into()),
1522 ));
1523 values.push((
1524 "profile.release.line_prefix",
1525 config
1526 .profile
1527 .release
1528 .line_prefix
1529 .clone()
1530 .map(toml_edit::Value::from),
1531 ));
1532 }
1533 if let Some(trunk) = config.git.trunk.clone() {
1534 values.push(("git.trunk", Some(trunk.into())));
1535 }
1536 if let Some(mode) = config.git.checkout_mode {
1537 values.push(("git.checkout_mode", Some(mode.as_str().into())));
1538 }
1539 if let Some(mode) = config.git.integration {
1540 values.push(("git.integration", Some(mode.as_str().into())));
1541 let mut bypass = toml_edit::Array::new();
1545 for actor in &config.protection.bypass_actors {
1546 bypass.push(actor.as_str());
1547 }
1548 values.push((
1549 "protection.bypass_actors",
1550 Some(toml_edit::Value::Array(bypass)),
1551 ));
1552 let mut rules = toml_edit::Array::new();
1553 for rule in &config.protection.owned_trunk_rules {
1554 rules.push(rule.as_str());
1555 }
1556 values.push((
1557 "protection.owned_trunk_rules",
1558 Some(toml_edit::Value::Array(rules)),
1559 ));
1560 values.push((
1561 "protection.gitlab.push_access_level",
1562 Some(config.protection.gitlab.push_access_level.into()),
1563 ));
1564 }
1565 if let Some(value) = config.capabilities.nix_packaging {
1566 values.push(("capabilities.nix_packaging", Some(value.into())));
1567 }
1568 if let Some(value) = config.capabilities.reporting_policy {
1569 values.push(("capabilities.reporting_policy", Some(value.into())));
1570 }
1571 if let Some(value) = config.capabilities.scorecard {
1572 values.push(("capabilities.scorecard", Some(value.into())));
1573 }
1574 if let Some(value) = config.capabilities.code_scanning.clone() {
1575 values.push(("capabilities.code_scanning", Some(value.into())));
1576 }
1577 if let Some(value) = config.security.contact.clone() {
1580 values.push(("security.contact", Some(value.into())));
1581 }
1582 if let Some(value) = config.security.response.clone() {
1583 values.push(("security.response", Some(value.into())));
1584 }
1585 values.push((
1590 "setup.required_check",
1591 Some(config.setup.required_check.clone().into()),
1592 ));
1593 values.push((
1594 "setup.required_workflow",
1595 Some(config.setup.required_workflow.clone().into()),
1596 ));
1597 values
1598}
1599
1600#[must_use]
1602pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
1603 let recorded = {
1604 let params = crate::landing::Params::from_record(record);
1605 Plan::compose(None, ¶ms, None, None)
1606 .ok()
1607 .and_then(|plan| parse(&plan.content).ok())
1608 };
1609 let Some(recorded) = recorded else {
1610 return Vec::new();
1611 };
1612 let render = |value: &Option<toml_edit::Value>| {
1613 value.as_ref().map_or_else(
1614 || "<absent>".to_owned(),
1615 |value| value.to_string().trim().to_owned(),
1616 )
1617 };
1618 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
1619 .iter()
1620 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1621 .map(|(key, value)| (*key, render(value)))
1622 .collect();
1623 parameter_values(config)
1624 .into_iter()
1625 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1626 .map(|(key, value)| (key, render(&value)))
1627 .filter(|(key, value)| {
1628 baseline
1629 .iter()
1630 .any(|(other, old)| key == other && value != old)
1631 })
1632 .map(|(key, _)| key.to_owned())
1633 .collect()
1634}
1635
1636#[must_use]
1644pub(crate) fn local_protection() -> Protection {
1645 const MAINTAINER: i64 = 40;
1647 Protection {
1648 bypass_actors: vec![LOCAL_GITHUB_BYPASS.into()],
1649 gitlab: Gitlab {
1650 push_access_level: MAINTAINER,
1651 ..Gitlab::default()
1652 },
1653 ..Protection::default()
1654 }
1655}
1656
1657#[must_use]
1660pub(crate) fn legacy_local_protection(policy: &Protection) -> bool {
1661 policy.bypass_actors.is_empty()
1662 && policy.owned_trunk_rules == ["deletion", "non_fast_forward"]
1663 && policy.gitlab.push_access_level >= 40
1664}
1665
1666#[must_use]
1688fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
1689 let forge = Protection::default();
1690 let local = local_protection();
1691 let tuple = |policy: &Protection| {
1692 (
1693 policy.bypass_actors.clone(),
1694 policy.owned_trunk_rules.clone(),
1695 policy.gitlab.push_access_level,
1696 )
1697 };
1698 let taken = fresh
1699 || tuple(held) == tuple(&forge)
1700 || tuple(held) == tuple(&local)
1701 || legacy_local_protection(held);
1702 if !taken {
1703 return held.clone();
1704 }
1705 let mut next = held.clone();
1706 let source = match integration {
1707 Integration::Forge => forge,
1708 Integration::Local => local,
1709 };
1710 next.bypass_actors = source.bypass_actors;
1711 next.owned_trunk_rules = source.owned_trunk_rules;
1712 next.gitlab.push_access_level = source.gitlab.push_access_level;
1713 next
1714}
1715
1716pub fn trunk_of(target: &Path) -> Result<String, RkError> {
1721 Ok(load(target)?
1722 .and_then(|config| config.git.trunk)
1723 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
1724}
1725
1726pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
1731 Ok(load(target)?
1732 .and_then(|config| config.profile.release.line_prefix)
1733 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
1734}
1735
1736#[cfg(test)]
1737mod tests {
1738 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
1739 use crate::landing::{CheckoutMode, Integration, Style};
1740 use crate::profile::ReleaseMode;
1741
1742 fn resolved_defaults() -> Config {
1745 Config {
1746 project: super::Project {
1747 repo: "acme/widget".into(),
1748 },
1749 profile: super::Profile {
1750 technologies: Some(vec!["rust".into()]),
1751 forge: Some("github".into()),
1752 release: super::Release {
1753 mode: Some(ReleaseMode::Automatic),
1754 driver: Some("rust".into()),
1755 style: Some(Style::Trunk),
1756 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
1757 },
1758 },
1759 git: super::Git {
1760 trunk: Some(super::TRUNK_DEFAULT.into()),
1761 checkout_mode: Some(CheckoutMode::LinkedWorktree),
1762 integration: Some(Integration::Local),
1763 },
1764 capabilities: super::Capabilities {
1765 nix_packaging: Some(false),
1766 reporting_policy: Some(true),
1767 scorecard: Some(false),
1768 code_scanning: Some("off".to_owned()),
1769 },
1770 security: super::Security {
1773 contact: Some(String::new()),
1774 response: Some(super::RESPONSE_DEFAULT.into()),
1775 ..super::Security::default()
1776 },
1777 protection: super::Protection {
1780 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
1781 safety_ruleset: Some(format!("{}-safety", super::TRUNK_DEFAULT)),
1782 ..super::local_protection()
1783 },
1784 ..Config::default()
1785 }
1786 }
1787
1788 #[test]
1789 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
1790 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
1791 let explicit = parse(
1792 "schema_version = 2\n[git]\ncheckout_mode = 'linked-worktree'\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk'\n[capabilities]\nnix_packaging = false\nreporting_policy = true\nscorecard = false\ncode_scanning = 'off'\n",
1793 )
1794 .expect("explicit defaults parse");
1795 assert_eq!(omitted.git, super::Git::default());
1796 assert_eq!(omitted.capabilities, super::Capabilities::default());
1797 assert_eq!(
1798 explicit.git.checkout_mode,
1799 Some(CheckoutMode::LinkedWorktree)
1800 );
1801 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
1802 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
1803 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
1804 assert_eq!(explicit.capabilities.scorecard, Some(false));
1805 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
1806 assert_ne!(omitted, explicit);
1807 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
1809 .expect("the older spelling reads");
1810 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
1811 }
1812
1813 #[test]
1817 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
1818 let dir = tempfile::tempdir().expect("a tempdir");
1819 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
1820 std::fs::write(
1821 dir.path().join(CONFIG_PATH),
1822 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
1823 )
1824 .expect("the config writes");
1825 let held = load(dir.path())
1826 .expect("the config reads")
1827 .expect("it is present");
1828 assert_eq!(held.setup.bot.app_id, "123");
1829 assert_eq!(
1830 held.setup.bot.installation_id,
1831 Some(0),
1832 "the key parses; nothing reads it"
1833 );
1834 }
1835
1836 #[test]
1838 fn a_schema_1_config_migrates_into_its_domains() {
1839 let held = parse(
1840 "schema_version = 1\n[project]\nrepo = 'acme/widget'\nforge = 'gitlab'\ntech = 'bash'\ntrunk = 'main'\n[landing]\nworkflow = 'branches'\nstyle = 'lines'\nnix = false\n[setup]\nline_prefix = 'stable/'\n",
1841 )
1842 .expect("a schema 1 file reads");
1843 assert_eq!(held.schema_version, 2);
1844 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
1845 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
1846 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
1847 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
1848 assert_eq!(held.profile.release.style, Some(Style::Lines));
1849 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
1850 assert_eq!(held.git.trunk.as_deref(), Some("main"));
1851 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
1852 assert_eq!(held.capabilities.nix_packaging, Some(false));
1853 assert_eq!(held.capabilities.reporting_policy, Some(true));
1854 }
1855
1856 #[test]
1858 fn every_invalid_release_state_names_its_key() {
1859 for (text, key) in [
1860 (
1861 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
1862 "profile.release.style",
1863 ),
1864 (
1865 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
1866 "profile.release.driver",
1867 ),
1868 (
1869 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
1870 "profile.release.line_prefix",
1871 ),
1872 (
1873 "[profile]\ntechnologies = ['rust', 'rust']\n",
1874 "profile.technologies",
1875 ),
1876 (
1877 "[profile]\ntechnologies = ['Rust!']\n",
1878 "profile.technologies",
1879 ),
1880 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
1881 (
1882 "[profile.release]\nmode = 'manual'\n",
1883 "profile.release.mode",
1884 ),
1885 ] {
1886 let error = parse(&format!("schema_version = 2\n{text}"))
1887 .expect_err("an invalid release state refuses")
1888 .to_string();
1889 assert!(error.contains(key), "{key}: {error}");
1890 }
1891 }
1892
1893 #[test]
1897 fn a_newly_written_key_takes_the_templates_comment() {
1898 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
1899 let next = super::rewrite_text(
1900 text,
1901 "git.integration",
1902 Some(toml_edit::Value::from("forge")),
1903 )
1904 .expect("the key writes");
1905 assert!(
1906 next.contains("integration = \"forge\" # P: local or forge"),
1907 "{next}"
1908 );
1909 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
1911 .expect("the key writes");
1912 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
1913 }
1914
1915 #[test]
1916 fn the_landed_config_template_round_trips() {
1917 let dir = tempfile::tempdir().expect("a target exists");
1918 let mut config = Config::default();
1919 config.project.repo = "acme/nested/widget".into();
1920 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
1921 config.profile.forge = Some("gitlab".into());
1922 config.profile.release = super::Release {
1923 mode: Some(ReleaseMode::Automatic),
1924 driver: Some("bash".into()),
1925 style: Some(Style::Lines),
1926 line_prefix: Some("stable/".into()),
1927 };
1928 config.git.trunk = Some("main".into());
1929 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
1930 config.git.integration = Some(Integration::Forge);
1931 config.capabilities.nix_packaging = Some(true);
1932 config.capabilities.reporting_policy = Some(false);
1933 config.capabilities.scorecard = Some(true);
1934 config.capabilities.code_scanning = Some("semgrep".to_owned());
1935 config.security.advisories =
1939 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
1940 config.security.contact = Some("security team, room 3 \"the vault\"".into());
1941 config.security.response = Some("14 business days".into());
1942 config.setup.required_check = "build / test".into();
1943 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
1944 config.setup.release_lines = true;
1945 config.setup.excluded_steps = [
1946 (
1947 "package-check".to_owned(),
1948 "nothing is published".to_owned(),
1949 ),
1950 (
1951 "protect-trunk".to_owned(),
1952 "this project merges \"locally\"".to_owned(),
1953 ),
1954 ]
1955 .into_iter()
1956 .collect();
1957 config.setup.bot.app_id = "123".into();
1958 config.protection.trunk_ruleset = Some("primary".into());
1959 config.protection.safety_ruleset = Some("guardrail".into());
1960 config.protection.tag_ruleset = "versions".into();
1961 config.protection.lines_ruleset = "maintenance".into();
1962 config.protection.title_check = "intent".into();
1963 config.protection.tag_pattern = "refs/tags/*".into();
1964 config
1965 .protection
1966 .owned_trunk_rules
1967 .push("required_signatures".into());
1968 config.protection.required_approving_review_count = 2;
1969 config.protection.dismiss_stale_reviews_on_push = true;
1970 config.protection.require_code_owner_review = true;
1971 config.protection.require_last_push_approval = true;
1972 config.protection.gitlab.squash_commit_template =
1973 "%{title}\n\nContext: %{description}".into();
1974 config.protection.gitlab.merge_access_level = 40;
1975 let mut release_less = resolved_defaults();
1978 release_less.project.repo = String::new();
1979 release_less.profile.technologies = Some(Vec::new());
1980 release_less.profile.forge = Some(String::new());
1981 release_less.profile.release = super::Release {
1982 mode: Some(ReleaseMode::None),
1983 driver: None,
1984 style: None,
1985 line_prefix: None,
1986 };
1987 release_less.capabilities.reporting_policy = Some(false);
1988 for expected in [resolved_defaults(), config, release_less] {
1989 write(dir.path(), &expected).expect("the template renders");
1990 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
1991 let text =
1992 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1993 assert!(text.contains("# P: every technology"));
1994 assert!(text.contains("# F: invariant"));
1995 }
1996 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1997 assert!(
1998 !text.contains("style ="),
1999 "a none release writes no style: {text}"
2000 );
2001 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
2002 }
2003
2004 #[test]
2005 fn a_config_with_an_unknown_key_refuses_by_name() {
2006 for (table, typo, nearest) in [
2007 ("", "schemax_version", "schema_version"),
2008 ("git", "trunkx", "trunk"),
2009 ("profile.release", "stile", "style"),
2010 ("capabilities", "nix_packagingg", "nix_packaging"),
2011 ("security", "contactx", "contact"),
2012 ("setup", "required_checkx", "required_check"),
2013 ("setup.bot", "app_i", "app_id"),
2014 ("protection", "trunk_rulesett", "trunk_ruleset"),
2015 (
2016 "protection.github",
2017 "squash_body_sourcex",
2018 "squash_body_source",
2019 ),
2020 ("protection.gitlab", "squash_optionx", "squash_option"),
2021 ] {
2022 let header = if table.is_empty() {
2023 String::new()
2024 } else {
2025 format!("[{table}]\n")
2026 };
2027 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
2028 let error = parse(&text).expect_err("unknown keys refuse").to_string();
2029 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
2030 assert!(error.contains(expected), "{error}");
2031 }
2032 }
2033 }
2034
2035 #[test]
2039 fn an_exclusion_names_a_real_step_and_states_why() {
2040 for (text, expected) in [
2041 (
2042 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
2043 vec!["protect-trunkk", "nearest known step: protect-trunk"],
2044 ),
2045 (
2046 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
2047 vec!["protect-trunk", "no reason"],
2048 ),
2049 ] {
2050 let error = parse(&format!("schema_version = 2\n{text}"))
2051 .expect_err("the exclusion refuses")
2052 .to_string();
2053 for want in expected {
2054 assert!(error.contains(want), "{error}");
2055 }
2056 }
2057 let held = parse(
2058 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
2059 )
2060 .expect("a named step with a reason parses");
2061 assert_eq!(
2062 held.setup
2063 .excluded_steps
2064 .get("protect-trunk")
2065 .map(String::as_str),
2066 Some("we merge locally")
2067 );
2068 }
2069
2070 #[test]
2074 fn an_exclusion_does_not_lift_a_floor() {
2075 let error = parse(
2076 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
2077 )
2078 .expect_err("the floor binds an excluded step's keys too")
2079 .to_string();
2080 assert!(
2081 error.contains("protection.allowed_merge_methods"),
2082 "{error}"
2083 );
2084 }
2085
2086 #[test]
2087 fn a_config_at_an_unknown_schema_refuses() {
2088 for text in ["schema_version = 999", "schema_version = '2'", ""] {
2089 let error = parse(text)
2090 .expect_err("a schema must be declared and known")
2091 .to_string();
2092 assert!(
2093 error.contains(CONFIG_PATH) && error.contains("schema_version"),
2094 "{error}"
2095 );
2096 }
2097 }
2098
2099 #[test]
2100 fn an_unparsable_config_refuses_naming_the_position() {
2101 let error = parse("schema_version = 2\n[project\n")
2102 .expect_err("bad TOML refuses")
2103 .to_string();
2104 for expected in [CONFIG_PATH, "line 2", "column"] {
2105 assert!(error.contains(expected), "{error}");
2106 }
2107 }
2108
2109 #[test]
2110 fn an_absent_config_reads_as_none() {
2111 let dir = tempfile::tempdir().expect("a target exists");
2112 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2113 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2114 }
2115
2116 #[test]
2117 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2118 let dir = tempfile::tempdir().expect("a target exists");
2119 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2120 std::fs::write(
2121 dir.path().join(CONFIG_PATH),
2122 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2123 )
2124 .expect("a config exists");
2125 let error =
2126 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2127 assert_eq!(error.exit_code(), 73);
2128 assert!(
2129 error
2130 .to_string()
2131 .contains("protection.strict_required_status_checks")
2132 );
2133 }
2134
2135 #[test]
2136 fn rewrite_key_preserves_comments() {
2137 let dir = tempfile::tempdir().expect("a target exists");
2138 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2139 let original = "# Project answers\nschema_version = 2\n\n[security] # first table stays first\ncontact = 'team' # keep me\n\n[profile.release]\n# Our release choice\nmode = 'automatic'\nstyle = 'trunk' # keep this reason\n\n[git]\ncheckout_mode = 'main-worktree'\n";
2140 let path = dir.path().join(CONFIG_PATH);
2141 std::fs::write(&path, original).expect("a config exists");
2142 rewrite_key(dir.path(), "profile.release.style", "lines".into())
2143 .expect("the style writes back");
2144 let text = std::fs::read_to_string(&path).expect("the text reads");
2145 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2146 assert_eq!(
2147 load(dir.path())
2148 .expect("the config reads")
2149 .expect("present")
2150 .profile
2151 .release
2152 .style,
2153 Some(Style::Lines)
2154 );
2155 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2156 .expect("an omitted table can be added");
2157 assert_eq!(
2158 load(dir.path())
2159 .expect("reads")
2160 .expect("present")
2161 .project
2162 .repo,
2163 "acme/widget"
2164 );
2165 rewrite_key(
2166 dir.path(),
2167 "security.contact",
2168 "security@acme.example".into(),
2169 )
2170 .expect("the contact is a landing parameter");
2171 rewrite_key(dir.path(), "security.response", "14 days".into())
2172 .expect("the response is a landing parameter");
2173 let held = load(dir.path()).expect("reads").expect("present");
2174 assert_eq!(
2175 held.security.contact.as_deref(),
2176 Some("security@acme.example")
2177 );
2178 assert_eq!(held.security.response.as_deref(), Some("14 days"));
2179 let text = std::fs::read_to_string(&path).expect("the text reads");
2180 assert!(text.contains("# keep me"), "the comment survives: {text}");
2181 let before = std::fs::read(&path).expect("the bytes read");
2182 for (key, value) in [
2183 ("security.advisories", "acme/private"),
2184 ("security.response", "90d"),
2185 ("profile.release.style", "unknown"),
2186 ] {
2187 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2188 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2189 }
2190 }
2191
2192 #[test]
2195 fn the_security_answers_are_held_to_their_grammar() {
2196 for value in ["team@acme.example", " https://acme.example/report ", ""] {
2197 super::canonical_contact(value).expect("a control-free line is a contact");
2198 }
2199 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2200 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2201 assert!(refusal.contains("security.contact"), "{refusal}");
2202 }
2203 assert_eq!(
2204 super::canonical_contact(" team@acme.example "),
2205 Ok("team@acme.example".to_owned()),
2206 "surrounding whitespace is trimmed"
2207 );
2208 for value in [
2209 "best-effort",
2210 "1 day",
2211 "2 days",
2212 "14 days",
2213 "1 business day",
2214 "14 business days",
2215 ] {
2216 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2217 }
2218 assert_eq!(
2219 super::canonical_response(""),
2220 Ok(super::RESPONSE_DEFAULT.to_owned()),
2221 "an empty answer reads as the compiled default"
2222 );
2223 for value in [
2224 "0 days",
2225 "1 days",
2226 "2 day",
2227 "+2 days",
2228 "02 days",
2229 "4294967296 days",
2230 "90d",
2231 "two days",
2232 "we answer quickly",
2233 "2 weeks",
2234 ] {
2235 let refusal =
2236 super::canonical_response(value).expect_err("an unstateable window refuses");
2237 assert!(refusal.contains("security.response"), "{value}: {refusal}");
2238 assert!(refusal.contains("business days"), "{value}: {refusal}");
2239 }
2240 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2241 .expect_err("the reader refuses it too")
2242 .to_string();
2243 assert!(refusal.contains("security.response"), "{refusal}");
2244 }
2245
2246 #[test]
2251 fn a_pruned_header_leaves_no_comment_behind() {
2252 let cases = [
2253 (
2254 "a middle table, comment above",
2255 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2256 ),
2257 (
2258 "a middle table, comment inline",
2259 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2260 ),
2261 (
2262 "the last table, comment above",
2263 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
2264 ),
2265 (
2266 "the last table, comment inline",
2267 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
2268 ),
2269 ];
2270 for (case, text) in cases {
2271 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2272 assert!(!next.contains("repo ="), "{case}: {next}");
2273 assert!(
2274 !next.contains("[project]"),
2275 "{case}: a table every one of whose keys dropped goes with them: {next}"
2276 );
2277 assert!(
2278 next.contains("# the operator's note"),
2279 "{case}: the comment the header carried survives: {next}"
2280 );
2281 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2282 }
2283 }
2284
2285 #[test]
2291 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2292 let text = concat!(
2293 "schema_version = 2\n\n[project]\n",
2294 "# the operator's note\n",
2295 "repo = \"acme/widget\" # P: project path on the forge\n\n",
2296 "[git]\ntrunk = \"main\"\n"
2297 );
2298 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2299 assert!(!next.contains("repo ="), "{next}");
2300 assert!(!next.contains("[project]"), "{next}");
2301 assert!(
2302 next.contains("# the operator's note"),
2303 "authored text survives: {next}"
2304 );
2305 assert!(
2306 !next.contains("# P:"),
2307 "the template's comment describes a key that is gone: {next}"
2308 );
2309 parse(&next).expect("the result parses");
2310
2311 let text = concat!(
2313 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2314 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2315 "# why this project names its own prefix\n",
2316 "line_prefix = \"stable/\"\n"
2317 );
2318 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2319 .expect("the key removes");
2320 assert!(!next.contains("line_prefix ="), "{next}");
2321 assert!(next.contains("[profile.release]"), "{next}");
2322 assert!(
2323 next.contains("# why this project names its own prefix"),
2324 "authored text survives: {next}"
2325 );
2326 }
2327}