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, MergeForm, Style};
17use crate::profile::ReleaseMode;
18use serde::{Deserialize, Serialize};
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 migration: Option<String>,
36 pub project: Project,
38 pub profile: Profile,
40 pub git: Git,
42 pub capabilities: Capabilities,
44 pub security: Security,
46 pub setup: Setup,
48 pub protection: Protection,
50 pub acquisition: Option<Acquisition>,
52}
53
54impl Default for Config {
55 fn default() -> Self {
56 Self {
57 schema_version: SCHEMA_VERSION,
58 migration: None,
59 project: Project::default(),
60 profile: Profile::default(),
61 git: Git::default(),
62 capabilities: Capabilities::default(),
63 security: Security::default(),
64 setup: Setup::default(),
65 protection: Protection::default(),
66 acquisition: None,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum AcquisitionSource {
75 Host,
77}
78
79impl AcquisitionSource {
80 #[must_use]
82 pub const fn as_str(self) -> &'static str {
83 match self {
84 Self::Host => "host",
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
97#[serde(deny_unknown_fields)]
98pub struct Acquisition {
99 pub source: AcquisitionSource,
101 pub minimum: String,
103}
104
105impl Acquisition {
106 #[must_use]
108 pub fn host_at_this_release() -> Self {
109 Self {
110 source: AcquisitionSource::Host,
111 minimum: env!("CARGO_PKG_VERSION").to_owned(),
112 }
113 }
114}
115
116fn is_release_version(value: &str) -> bool {
119 let core = value.split(['-', '+']).next().unwrap_or_default();
120 let parts: Vec<&str> = core.split('.').collect();
121 parts.len() == 3
122 && parts
123 .iter()
124 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
125 && value[core.len()..]
126 .chars()
127 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
132#[serde(deny_unknown_fields, default)]
133pub struct Project {
134 pub repo: String,
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
141#[serde(deny_unknown_fields, default)]
142pub struct Profile {
143 pub technologies: Option<Vec<String>>,
145 pub forge: Option<String>,
148 pub release: Release,
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
154#[serde(deny_unknown_fields, default)]
155pub struct Release {
156 pub mode: Option<ReleaseMode>,
159 pub driver: Option<String>,
162 pub style: Option<Style>,
164 pub line_prefix: Option<String>,
166}
167
168#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
170#[serde(deny_unknown_fields, default)]
171pub struct Git {
172 pub trunk: Option<String>,
176 pub checkout_mode: Option<CheckoutMode>,
178 pub integration: Option<Integration>,
181 pub merge_form: Option<MergeForm>,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
187#[serde(deny_unknown_fields, default)]
188pub struct Capabilities {
189 pub nix_packaging: Option<bool>,
191 pub reporting_policy: Option<bool>,
193 pub scorecard: Option<bool>,
195 pub code_scanning: Option<String>,
199 pub workflow_guards: Option<bool>,
202}
203
204pub const TRUNK_DEFAULT: &str = "master";
206
207pub const LINE_PREFIX_DEFAULT: &str = "release/";
209
210#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
212#[serde(deny_unknown_fields, default)]
213pub struct Security {
214 pub advisories: String,
216 pub contact: Option<String>,
221 pub response: Option<String>,
224}
225
226pub const RESPONSE_DEFAULT: &str = "best-effort";
229
230pub const LOCAL_GITHUB_BYPASS: &str = "repository-admin";
236
237pub fn canonical_contact(raw: &str) -> Result<String, String> {
247 let trimmed = raw.trim();
248 if trimmed.chars().any(char::is_control) {
249 return Err(format!(
250 "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:?}"
251 ));
252 }
253 Ok(trimmed.to_owned())
254}
255
256pub fn canonical_response(raw: &str) -> Result<String, String> {
268 let trimmed = raw.trim();
269 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
270 return Ok(RESPONSE_DEFAULT.to_owned());
271 }
272 let refusal = || {
273 format!(
274 "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:?}"
275 )
276 };
277 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
278 let plural = match unit {
279 "day" | "business day" => false,
280 "days" | "business days" => true,
281 _ => return Err(refusal()),
282 };
283 let number: u32 = count.parse().map_err(|_| refusal())?;
284 if count != number.to_string() || (number > 1) != plural {
287 return Err(refusal());
288 }
289 Ok(trimmed.to_owned())
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
294#[serde(deny_unknown_fields, default)]
295pub struct Setup {
296 pub required_check: String,
299 pub required_workflow: String,
303 pub retired_branches: Vec<String>,
305 pub release_lines: bool,
307 pub excluded_steps: BTreeMap<String, String>,
312 pub bot: Bot,
314}
315
316impl Default for Setup {
317 fn default() -> Self {
318 Self {
319 required_check: String::new(),
320 required_workflow: String::new(),
321 retired_branches: vec!["main".into(), "develop".into()],
322 release_lines: false,
323 excluded_steps: BTreeMap::new(),
324 bot: Bot::default(),
325 }
326 }
327}
328
329#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
336#[serde(deny_unknown_fields, default)]
337pub struct Bot {
338 pub app_id: String,
340 #[serde(default, skip_serializing)]
345 pub installation_id: Option<i64>,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
350#[serde(deny_unknown_fields, default)]
351#[allow(
352 clippy::struct_excessive_bools,
353 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
354)]
355pub struct Protection {
356 pub trunk_ruleset: Option<String>,
360 pub safety_ruleset: Option<String>,
365 pub tag_ruleset: String,
367 pub lines_ruleset: String,
369 pub title_check: String,
371 pub tag_pattern: String,
373 pub bypass_actors: Vec<String>,
376 pub allowed_merge_methods: Vec<String>,
379 pub strict_required_status_checks: bool,
381 pub owned_trunk_rules: Vec<String>,
385 pub required_approving_review_count: i64,
387 pub dismiss_stale_reviews_on_push: bool,
389 pub require_code_owner_review: bool,
391 pub require_last_push_approval: bool,
393 pub github: Github,
395 pub gitlab: Gitlab,
397}
398
399impl Default for Protection {
400 fn default() -> Self {
401 Self {
402 trunk_ruleset: None,
403 safety_ruleset: None,
404 tag_ruleset: "release-tags".into(),
405 lines_ruleset: "release-lines".into(),
406 title_check: "pr-title".into(),
407 tag_pattern: "refs/tags/v*".into(),
408 bypass_actors: Vec::new(),
409 allowed_merge_methods: vec!["squash".into()],
410 strict_required_status_checks: true,
411 owned_trunk_rules: vec![
412 "deletion".into(),
413 "non_fast_forward".into(),
414 "pull_request".into(),
415 "required_status_checks".into(),
416 ],
417 required_approving_review_count: 0,
418 dismiss_stale_reviews_on_push: false,
419 require_code_owner_review: false,
420 require_last_push_approval: false,
421 github: Github::default(),
422 gitlab: Gitlab::default(),
423 }
424 }
425}
426
427impl Protection {
428 #[must_use]
431 pub fn trunk_ruleset(&self, trunk: &str) -> String {
432 self.trunk_ruleset
433 .clone()
434 .unwrap_or_else(|| format!("{trunk}-protection"))
435 }
436
437 #[must_use]
444 pub fn safety_ruleset(&self, trunk: &str) -> String {
445 self.safety_ruleset
446 .clone()
447 .unwrap_or_else(|| format!("{trunk}-safety"))
448 }
449}
450
451pub const SAFETY_RULES: [&str; 3] = ["deletion", "non_fast_forward", LINEAR_HISTORY_RULE];
461
462pub const COMMIT_GATE_CHECK: &str = "pr-commits";
466
467pub const LINEAR_HISTORY_RULE: &str = "required_linear_history";
471
472pub const REQUEST_RULES: [&str; 2] = ["pull_request", "required_status_checks"];
475
476#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
478#[serde(deny_unknown_fields, default)]
479pub struct Github {
480 pub squash_title_source: String,
483 pub squash_body_source: String,
485}
486
487impl Default for Github {
488 fn default() -> Self {
489 Self {
490 squash_title_source: "PR_TITLE".into(),
491 squash_body_source: "PR_BODY".into(),
492 }
493 }
494}
495
496#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
498#[serde(deny_unknown_fields, default)]
499pub struct Gitlab {
500 pub merge_method: String,
502 pub squash_option: String,
506 pub squash_commit_template: String,
508 pub push_access_level: i64,
513 pub merge_access_level: i64,
515}
516
517impl Default for Gitlab {
518 fn default() -> Self {
519 Self {
520 merge_method: "ff".into(),
521 squash_option: "always".into(),
522 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
523 .trim_end_matches('\n')
524 .to_owned(),
525 push_access_level: 0,
526 merge_access_level: 40,
527 }
528 }
529}
530
531pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
536 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
537 Ok(text) => text,
538 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
539 Err(error) => return Err(error.into()),
540 };
541 parse(&text).map(Some)
542}
543
544fn current_text(text: &str) -> Result<String, RkError> {
547 let raw: toml::Value =
548 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
549 match raw.get("schema_version").and_then(toml::Value::as_integer) {
550 Some(1) => migrate::to_schema_2(text),
551 _ => Ok(text.to_owned()),
552 }
553}
554
555fn parse(text: &str) -> Result<Config, RkError> {
556 let text = current_text(text)?;
557 let raw: toml::Value =
558 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
559 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
560 return Err(invalid(format!(
561 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
562 )));
563 }
564 if let Some(mode) = raw
568 .get("profile")
569 .and_then(|profile| profile.get("release"))
570 .and_then(|release| release.get("mode"))
571 {
572 let named = mode.as_str().ok_or_else(|| {
573 invalid("profile.release.mode must be a string: automatic, external, or none")
574 })?;
575 crate::profile::ReleaseMode::parse(named)
576 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
577 }
578 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
579 let mut message = error.to_string();
580 if let Some(rest) = error.message().strip_prefix("unknown field `") {
581 let names: Vec<_> = rest.split('`').collect();
582 if let Some(unknown) = names.first()
583 && let Some(nearest) = names
584 .iter()
585 .skip(2)
586 .step_by(2)
587 .min_by_key(|name| distance(unknown, name))
588 {
589 let _ = write!(message, "; nearest known key: {nearest}");
590 }
591 }
592 invalid(message)
593 })?;
594 if let Some(technologies) = &config.profile.technologies {
595 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
596 }
597 if let Some(forge) = &config.profile.forge
598 && !forge.is_empty()
599 {
600 crate::profile::canonical_category(forge)
601 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
602 }
603 if let Some(driver) = &config.profile.release.driver {
604 crate::profile::canonical_category(driver)
605 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
606 }
607 if matches!(
608 config.profile.release.mode,
609 Some(ReleaseMode::External | ReleaseMode::None)
610 ) {
611 let mode = config
612 .profile
613 .release
614 .mode
615 .map_or("none", ReleaseMode::as_str);
616 for (key, present) in [
617 (
618 "profile.release.driver",
619 config.profile.release.driver.is_some(),
620 ),
621 (
622 "profile.release.style",
623 config.profile.release.style.is_some(),
624 ),
625 (
626 "profile.release.line_prefix",
627 config.profile.release.line_prefix.is_some(),
628 ),
629 ] {
630 if present {
631 return Err(invalid(format!(
632 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
633 )));
634 }
635 }
636 }
637 if let Some(contact) = &config.security.contact {
638 canonical_contact(contact).map_err(invalid)?;
639 }
640 if let Some(response) = &config.security.response {
641 canonical_response(response).map_err(invalid)?;
642 }
643 exclusions(&config.setup.excluded_steps)?;
644 if let Some(acquisition) = &config.acquisition
645 && !is_release_version(&acquisition.minimum)
646 {
647 return Err(invalid(format!(
648 "acquisition.minimum is {}, which is no release version; write one such as {}",
649 acquisition.minimum,
650 env!("CARGO_PKG_VERSION")
651 )));
652 }
653 migration_key(&config)?;
654 floors::check(&config)?;
655 Ok(config)
656}
657
658fn migration_key(config: &Config) -> Result<(), RkError> {
661 match &config.migration {
662 Some(id) if !crate::migrate::is_id(id) => Err(invalid(format!(
663 "migration is {id}, which is no migration identifier; rk migrate archive writes one such as 20261009T140205Z (target-config:an-open-migration-is-one-top-level-key)"
664 ))),
665 _ => Ok(()),
666 }
667}
668
669fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
676 for (name, reason) in excluded {
677 if crate::setup::steps::spec(name).is_none() {
678 let nearest = crate::setup::steps::STEPS
679 .iter()
680 .min_by_key(|step| distance(name, step.name))
681 .map_or("", |step| step.name);
682 return Err(invalid(format!(
683 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
684 )));
685 }
686 if reason.trim().is_empty() {
687 return Err(invalid(format!(
688 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
689 )));
690 }
691 }
692 Ok(())
693}
694
695pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
696 RkError::refusal(
697 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
698 .action(format!("edit {CONFIG_PATH} and retry"))
699 .target_state("nothing was written"),
700 )
701}
702
703fn distance(left: &str, right: &str) -> usize {
704 let mut row: Vec<_> = (0..=right.chars().count()).collect();
705 for (i, a) in left.chars().enumerate() {
706 let mut previous = row[0];
707 row[0] = i + 1;
708 for (j, b) in right.chars().enumerate() {
709 let old = row[j + 1];
710 row[j + 1] = (previous + usize::from(a != b))
711 .min(row[j] + 1)
712 .min(old + 1);
713 previous = old;
714 }
715 }
716 row.last().copied().unwrap_or(0)
717}
718
719pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
725 let bytes = render(config)?;
726 parse(&String::from_utf8_lossy(&bytes))?;
727 write_checked(target, &bytes)
728}
729
730fn write_checked(target: &Path, bytes: &[u8]) -> Result<(), RkError> {
733 check_writable(target)?;
734 crate::atomic::write(&target.join(CONFIG_PATH), bytes)?;
735 Ok(())
736}
737
738fn array(values: &[String]) -> toml_edit::Value {
739 toml_edit::Value::Array(values.iter().collect())
740}
741
742fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
747 let mut table = toml_edit::InlineTable::new();
748 for (key, value) in values {
749 table.insert(key, value.clone().into());
750 }
751 toml_edit::Value::InlineTable(table)
752}
753
754#[allow(
759 clippy::too_many_lines,
760 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
761)]
762fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
763 let trunk = config
767 .git
768 .trunk
769 .clone()
770 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
771 let technologies = config
772 .profile
773 .technologies
774 .clone()
775 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
776 let forge = config
777 .profile
778 .forge
779 .clone()
780 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
781 let mode = config
782 .profile
783 .release
784 .mode
785 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
786 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
787 (
788 "RK_CONFIG_SCHEMA_VERSION",
789 Some(config.schema_version.into()),
790 ),
791 (
792 "RK_CONFIG_PROJECT_REPO",
793 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
794 ),
795 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
796 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
797 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
798 (
799 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
800 config
801 .profile
802 .release
803 .driver
804 .clone()
805 .map(toml_edit::Value::from),
806 ),
807 (
808 "RK_CONFIG_PROFILE_RELEASE_STYLE",
809 config
810 .profile
811 .release
812 .style
813 .map(|style| style.as_str().into()),
814 ),
815 (
816 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
817 config
818 .profile
819 .release
820 .line_prefix
821 .clone()
822 .map(toml_edit::Value::from),
823 ),
824 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
825 (
826 "RK_CONFIG_GIT_CHECKOUT_MODE",
827 Some(
828 config
829 .git
830 .checkout_mode
831 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
832 .as_str()
833 .into(),
834 ),
835 ),
836 (
837 "RK_CONFIG_GIT_INTEGRATION",
838 Some(
839 config
840 .git
841 .integration
842 .ok_or_else(|| invalid("git.integration is unresolved"))?
843 .as_str()
844 .into(),
845 ),
846 ),
847 (
848 "RK_CONFIG_GIT_MERGE_FORM",
849 Some(
850 config
851 .git
852 .merge_form
853 .ok_or_else(|| invalid("git.merge_form is unresolved"))?
854 .as_str()
855 .into(),
856 ),
857 ),
858 (
859 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
860 Some(
861 config
862 .capabilities
863 .nix_packaging
864 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
865 .into(),
866 ),
867 ),
868 (
869 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
870 Some(
871 config
872 .capabilities
873 .reporting_policy
874 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
875 .into(),
876 ),
877 ),
878 (
879 "RK_CONFIG_CAPABILITIES_SCORECARD",
880 Some(
881 config
882 .capabilities
883 .scorecard
884 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
885 .into(),
886 ),
887 ),
888 (
889 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
890 Some(
891 config
892 .capabilities
893 .code_scanning
894 .clone()
895 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
896 .into(),
897 ),
898 ),
899 (
900 "RK_CONFIG_CAPABILITIES_WORKFLOW_GUARDS",
901 Some(
902 config
903 .capabilities
904 .workflow_guards
905 .ok_or_else(|| invalid("capabilities.workflow_guards is unresolved"))?
906 .into(),
907 ),
908 ),
909 (
910 "RK_CONFIG_SECURITY_ADVISORIES",
911 Some(config.security.advisories.clone().into()),
912 ),
913 (
914 "RK_CONFIG_SECURITY_CONTACT",
915 Some(config.security.contact.clone().unwrap_or_default().into()),
916 ),
917 (
918 "RK_CONFIG_SECURITY_RESPONSE",
919 Some(
920 config
921 .security
922 .response
923 .clone()
924 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
925 .into(),
926 ),
927 ),
928 (
929 "RK_CONFIG_SETUP_REQUIRED_CHECK",
930 Some(config.setup.required_check.clone().into()),
931 ),
932 (
933 "RK_CONFIG_SETUP_REQUIRED_WORKFLOW",
934 Some(config.setup.required_workflow.clone().into()),
935 ),
936 (
937 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
938 Some(array(&config.setup.retired_branches)),
939 ),
940 (
941 "RK_CONFIG_SETUP_RELEASE_LINES",
942 Some(config.setup.release_lines.into()),
943 ),
944 (
945 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
946 Some(inline(&config.setup.excluded_steps)),
947 ),
948 (
949 "RK_CONFIG_SETUP_BOT_APP_ID",
950 Some(config.setup.bot.app_id.clone().into()),
951 ),
952 (
953 "RK_CONFIG_ACQUISITION_SOURCE",
954 config
955 .acquisition
956 .as_ref()
957 .map(|answer| answer.source.as_str().into()),
958 ),
959 (
960 "RK_CONFIG_ACQUISITION_MINIMUM",
961 config
962 .acquisition
963 .as_ref()
964 .map(|answer| answer.minimum.clone().into()),
965 ),
966 ];
967 fields.extend(
968 protection_fields(&config.protection, trunk.as_str())
969 .into_iter()
970 .map(|(token, value)| (token, Some(value))),
971 );
972 if mode == crate::profile::ReleaseMode::None {
978 let reporting = config.capabilities.reporting_policy.unwrap_or(false);
979 for (token, value) in &mut fields {
980 if RELEASE_ONLY_TOKENS.contains(token)
981 || (!reporting && SECURITY_TOKENS.contains(token))
982 {
983 *value = None;
984 }
985 }
986 }
987 Ok(fields)
988}
989
990const RELEASE_ONLY_TOKENS: [&str; 4] = [
996 "RK_CONFIG_PROTECTION_TAG_RULESET",
997 "RK_CONFIG_PROTECTION_TAG_PATTERN",
998 "RK_CONFIG_PROTECTION_LINES_RULESET",
999 "RK_CONFIG_SETUP_BOT_APP_ID",
1000];
1001
1002const SECURITY_TOKENS: [&str; 3] = [
1005 "RK_CONFIG_SECURITY_ADVISORIES",
1006 "RK_CONFIG_SECURITY_CONTACT",
1007 "RK_CONFIG_SECURITY_RESPONSE",
1008];
1009
1010fn render(config: &Config) -> Result<Vec<u8>, RkError> {
1011 let fields = fields(config)?;
1012 let template = crate::embedded::BLOCKS
1013 .get_file("target-config.toml.in")
1014 .and_then(include_dir::File::contents_utf8)
1015 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
1016 let lines: Vec<&str> = template.split_inclusive('\n').collect();
1022 let dropped: Vec<bool> = lines
1023 .iter()
1024 .map(|line| {
1025 matches!(
1026 fields.iter().find(|(token, _)| line.contains(token)),
1027 Some((_, None))
1028 )
1029 })
1030 .collect();
1031 let mut bytes = Vec::new();
1032 let mut at = 0;
1033 while at < lines.len() {
1034 let line = lines[at];
1035 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
1036 let mut end = at + 1;
1037 let mut keeps = false;
1038 while end < lines.len()
1039 && !(lines[end].trim_start().starts_with('[')
1040 && lines[end].trim_end().ends_with(']'))
1041 {
1042 keeps |=
1043 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
1044 end += 1;
1045 }
1046 if !keeps {
1047 while bytes.last() == Some(&b'\n')
1049 && bytes.len() >= 2
1050 && bytes[bytes.len() - 2] == b'\n'
1051 {
1052 bytes.pop();
1053 }
1054 at = end;
1055 continue;
1056 }
1057 }
1058 if !dropped[at] {
1059 match fields.iter().find(|(token, _)| line.contains(token)) {
1060 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
1061 line.as_bytes(),
1062 token.as_bytes(),
1063 value.to_string().as_bytes(),
1064 )),
1065 Some((_, None)) => {}
1066 None => bytes.extend_from_slice(line.as_bytes()),
1067 }
1068 }
1069 at += 1;
1070 }
1071 Ok(bytes)
1072}
1073
1074fn protection_fields(
1075 protection: &Protection,
1076 trunk: &str,
1077) -> Vec<(&'static str, toml_edit::Value)> {
1078 vec![
1079 (
1080 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
1081 protection.trunk_ruleset(trunk).into(),
1082 ),
1083 (
1084 "RK_CONFIG_PROTECTION_SAFETY_RULESET",
1085 protection.safety_ruleset(trunk).into(),
1086 ),
1087 (
1088 "RK_CONFIG_PROTECTION_TAG_RULESET",
1089 protection.tag_ruleset.clone().into(),
1090 ),
1091 (
1092 "RK_CONFIG_PROTECTION_LINES_RULESET",
1093 protection.lines_ruleset.clone().into(),
1094 ),
1095 (
1096 "RK_CONFIG_PROTECTION_TITLE_CHECK",
1097 protection.title_check.clone().into(),
1098 ),
1099 (
1100 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1101 protection.tag_pattern.clone().into(),
1102 ),
1103 (
1104 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
1105 array(&protection.bypass_actors),
1106 ),
1107 (
1108 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
1109 array(&protection.allowed_merge_methods),
1110 ),
1111 (
1112 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
1113 protection.strict_required_status_checks.into(),
1114 ),
1115 (
1116 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
1117 array(&protection.owned_trunk_rules),
1118 ),
1119 (
1120 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
1121 protection.required_approving_review_count.into(),
1122 ),
1123 (
1124 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
1125 protection.dismiss_stale_reviews_on_push.into(),
1126 ),
1127 (
1128 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
1129 protection.require_code_owner_review.into(),
1130 ),
1131 (
1132 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
1133 protection.require_last_push_approval.into(),
1134 ),
1135 (
1136 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
1137 protection.github.squash_title_source.clone().into(),
1138 ),
1139 (
1140 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
1141 protection.github.squash_body_source.clone().into(),
1142 ),
1143 (
1144 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
1145 protection.gitlab.merge_method.clone().into(),
1146 ),
1147 (
1148 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
1149 protection.gitlab.squash_option.clone().into(),
1150 ),
1151 (
1152 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
1153 protection.gitlab.squash_commit_template.clone().into(),
1154 ),
1155 (
1156 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
1157 protection.gitlab.push_access_level.into(),
1158 ),
1159 (
1160 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
1161 protection.gitlab.merge_access_level.into(),
1162 ),
1163 ]
1164}
1165
1166const DERIVED_POLICY_KEYS: [&str; 5] = [
1177 "protection.bypass_actors",
1178 "protection.owned_trunk_rules",
1179 "protection.gitlab.push_access_level",
1180 "protection.allowed_merge_methods",
1181 "protection.gitlab.squash_option",
1182];
1183
1184const PARAMETER_KEYS: [&str; 26] = [
1186 "project.repo",
1187 "profile.technologies",
1188 "profile.forge",
1189 "profile.release.mode",
1190 "profile.release.driver",
1191 "profile.release.style",
1192 "profile.release.line_prefix",
1193 "git.trunk",
1194 "git.checkout_mode",
1195 "git.integration",
1196 "git.merge_form",
1197 "protection.allowed_merge_methods",
1200 "protection.gitlab.squash_option",
1201 "protection.bypass_actors",
1205 "protection.owned_trunk_rules",
1206 "protection.gitlab.push_access_level",
1207 "capabilities.nix_packaging",
1208 "capabilities.reporting_policy",
1209 "capabilities.scorecard",
1210 "capabilities.code_scanning",
1211 "capabilities.workflow_guards",
1212 "security.contact",
1213 "security.response",
1214 "setup.required_check",
1217 "setup.required_workflow",
1218 "schema_version",
1219];
1220
1221pub fn with_acquisition(target: &Path, answer: &Acquisition) -> Result<(String, bool), RkError> {
1233 let path = target.join(CONFIG_PATH);
1234 let text = match std::fs::read_to_string(&path) {
1235 Ok(text) => text,
1236 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1237 Err(error) => return Err(error.into()),
1238 };
1239 if text.is_empty() {
1240 let template = crate::embedded::BLOCKS
1241 .get_file("acquisition-config.toml.in")
1242 .and_then(include_dir::File::contents_utf8)
1243 .ok_or_else(|| invalid("the binary lacks its acquisition template"))?;
1244 let mut fresh = template.as_bytes().to_vec();
1245 for (token, value) in [
1246 (
1247 "RK_CONFIG_SCHEMA_VERSION",
1248 toml_edit::Value::from(SCHEMA_VERSION),
1249 ),
1250 (
1251 "RK_CONFIG_ACQUISITION_SOURCE",
1252 toml_edit::Value::from(answer.source.as_str()),
1253 ),
1254 (
1255 "RK_CONFIG_ACQUISITION_MINIMUM",
1256 toml_edit::Value::from(answer.minimum.clone()),
1257 ),
1258 ] {
1259 fresh =
1260 crate::landing::substitute(&fresh, token.as_bytes(), value.to_string().as_bytes());
1261 }
1262 let fresh = String::from_utf8(fresh).map_err(|error| invalid(error.to_string()))?;
1263 parse(&fresh)?;
1264 return Ok((fresh, true));
1265 }
1266 let held = parse(&text)?;
1267 if held.acquisition.as_ref() == Some(answer) {
1268 return Ok((text, false));
1269 }
1270 let text = current_text(&text)?;
1271 let mut document = text
1272 .parse::<toml_edit::DocumentMut>()
1273 .map_err(|error| invalid(error.to_string()))?;
1274 if !document.contains_table("acquisition") {
1275 let mut table = toml_edit::Table::new();
1276 table.decor_mut().set_prefix("\n");
1277 document.insert("acquisition", toml_edit::Item::Table(table));
1278 }
1279 let table = document
1280 .get_mut("acquisition")
1281 .and_then(toml_edit::Item::as_table_mut)
1282 .ok_or_else(|| invalid("acquisition must be a table"))?;
1283 for (key, value, note) in [
1284 (
1285 "source",
1286 answer.source.as_str().to_owned(),
1287 " P: host where every host installs rk itself; absent where a manager file pins it",
1288 ),
1289 (
1290 "minimum",
1291 answer.minimum.clone(),
1292 " P: the lowest rk release a host may run against this project",
1293 ),
1294 ] {
1295 let kept = table
1296 .get(key)
1297 .and_then(toml_edit::Item::as_value)
1298 .map(|held| held.decor().clone());
1299 let mut value = toml_edit::Value::from(value);
1300 match kept {
1301 Some(decor) => *value.decor_mut() = decor,
1302 None => value.decor_mut().set_suffix(format!(" #{note}")),
1303 }
1304 table.insert(key, toml_edit::Item::Value(value));
1305 }
1306 let next = document.to_string();
1307 parse(&next)?;
1308 Ok((next, true))
1309}
1310
1311impl Config {
1312 #[must_use]
1315 pub fn states_only_a_migration(&self) -> bool {
1316 self.migration.is_some()
1317 && Self {
1318 migration: None,
1319 ..self.clone()
1320 } == Self::default()
1321 }
1322}
1323
1324pub fn with_migration(text: &str, id: Option<&str>) -> Result<String, RkError> {
1331 let mut document = text
1332 .parse::<toml_edit::DocumentMut>()
1333 .map_err(|error| invalid(error.to_string()))?;
1334 let root = document.as_table_mut();
1335 match id {
1336 Some(id) => {
1337 root.insert("migration", toml_edit::value(id));
1338 root.sort_values_by(|a, _, b, _| {
1340 let rank = |key: &toml_edit::Key| match key.get() {
1341 "schema_version" => 0,
1342 "migration" => 1,
1343 _ => 2,
1344 };
1345 rank(a).cmp(&rank(b))
1346 });
1347 }
1348 None => {
1349 root.remove("migration");
1350 }
1351 }
1352 let next = document.to_string();
1353 parse(&next)?;
1354 Ok(next)
1355}
1356
1357pub fn check_writable(target: &Path) -> Result<(), RkError> {
1367 let file = target.join(CONFIG_PATH);
1368 for (path, directory) in [(file.parent(), true), (Some(file.as_path()), false)] {
1369 let Some(path) = path else { continue };
1370 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1371 continue;
1372 };
1373 let kind = metadata.file_type();
1374 let fits = if directory {
1375 kind.is_dir()
1376 } else {
1377 kind.is_file()
1378 };
1379 if !fits {
1380 return Err(RkError::refusal(
1381 Diagnostic::new(
1382 Reason::StateDrift,
1383 format!(
1384 "{} is a symlink or not a regular {}, so a configuration write would not stay in the target",
1385 path.display(),
1386 if directory { "directory" } else { "file" }
1387 ),
1388 )
1389 .expected(format!("{CONFIG_PATH} as a regular file in a regular directory"))
1390 .action("replace the symlink with the directory or file it stands for")
1391 .target_state("unchanged"),
1392 ));
1393 }
1394 }
1395 Ok(())
1396}
1397
1398pub fn set_migration(target: &Path, id: Option<&str>) -> Result<(), RkError> {
1406 check_writable(target)?;
1407 let path = target.join(CONFIG_PATH);
1408 let text = match std::fs::read_to_string(&path) {
1409 Ok(text) => text,
1410 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1411 if id.is_none() {
1412 return Ok(());
1413 }
1414 format!("schema_version = {SCHEMA_VERSION}\n")
1415 }
1416 Err(error) => return Err(error.into()),
1417 };
1418 let next = with_migration(¤t_text(&text)?, id)?;
1419 write_checked(target, next.as_bytes())
1420}
1421
1422pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
1428 let path = target.join(CONFIG_PATH);
1429 let text = std::fs::read_to_string(&path)?;
1430 let next = rewrite_text(&text, key, Some(value))?;
1431 write_checked(target, next.as_bytes())
1432}
1433
1434fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
1437 rewrite_all(text, vec![(key, value)])
1438}
1439
1440fn rewrite_all(
1448 text: &str,
1449 values: Vec<(&str, Option<toml_edit::Value>)>,
1450) -> Result<String, RkError> {
1451 for (key, _) in &values {
1452 if !PARAMETER_KEYS.contains(key) {
1453 return Err(invalid(format!("{key} is not a landing parameter")));
1454 }
1455 }
1456 parse(text)?;
1457 let text = current_text(text)?;
1458 let mut document = text
1459 .parse::<toml_edit::DocumentMut>()
1460 .map_err(|error| invalid(error.to_string()))?;
1461 let mut changed = false;
1462 let mut emptied: Vec<&str> = Vec::new();
1465 for (key, value) in values {
1466 let removing = value.is_none();
1467 let applied = apply_key(&mut document, key, value)?;
1468 changed |= applied;
1469 if applied
1470 && removing
1471 && let Some(parent) = key.split('.').next()
1472 {
1473 emptied.push(parent);
1474 }
1475 }
1476 changed |= prune_empty_tables(&mut document, &emptied);
1477 changed |= migrate::refresh_comments(&mut document);
1484 if !changed {
1485 return Ok(text);
1486 }
1487 let next = document.to_string();
1488 parse(&next)?;
1489 Ok(next)
1490}
1491
1492pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1511 let order: Vec<String> = document
1512 .as_table()
1513 .iter()
1514 .map(|(key, _)| key.to_owned())
1515 .collect();
1516 let mut changed = false;
1517 for (index, name) in order.iter().enumerate() {
1518 if !names.contains(&name.as_str())
1519 || !document
1520 .get(name)
1521 .and_then(toml_edit::Item::as_table)
1522 .is_some_and(toml_edit::Table::is_empty)
1523 {
1524 continue;
1525 }
1526 let carried = document
1527 .get(name)
1528 .and_then(toml_edit::Item::as_table)
1529 .and_then(|table| carried_comment(table.decor()));
1530 document.remove(name);
1531 changed = true;
1532 let Some(carried) = carried else { continue };
1533 let next = order
1537 .iter()
1538 .skip(index + 1)
1539 .find(|name| {
1540 document
1541 .get(name)
1542 .and_then(toml_edit::Item::as_table)
1543 .is_some_and(|table| !table.is_implicit())
1544 })
1545 .cloned();
1546 if let Some(next) = next
1547 && let Some(table) = document
1548 .get_mut(&next)
1549 .and_then(toml_edit::Item::as_table_mut)
1550 {
1551 let existing = table
1552 .decor()
1553 .prefix()
1554 .and_then(toml_edit::RawString::as_str)
1555 .unwrap_or_default()
1556 .trim_start_matches('\n')
1557 .to_owned();
1558 table
1559 .decor_mut()
1560 .set_prefix(format!("\n{carried}{existing}"));
1561 } else {
1562 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1563 if !trailing.is_empty() && !trailing.ends_with('\n') {
1564 trailing.push('\n');
1565 }
1566 trailing.push_str(&carried);
1567 document.set_trailing(trailing);
1568 }
1569 }
1570 changed
1571}
1572
1573fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1580 let mut lines = String::new();
1581 for raw in [decor.prefix(), decor.suffix()] {
1582 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1583 continue;
1584 };
1585 for line in text
1586 .lines()
1587 .map(str::trim)
1588 .filter(|line| line.starts_with('#'))
1589 .filter(|line| !is_template_comment(line))
1590 {
1591 lines.push_str(line);
1592 lines.push('\n');
1593 }
1594 }
1595 (!lines.is_empty()).then_some(lines)
1596}
1597
1598fn is_template_comment(line: &str) -> bool {
1607 let rest = line.trim_start_matches('#').trim_start();
1608 ["P:", "N:", "F:"]
1609 .iter()
1610 .any(|marker| rest.starts_with(marker))
1611}
1612
1613pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1621 let first = document
1622 .as_table()
1623 .iter()
1624 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1625 .map(|(name, _)| name.to_owned());
1626 if let Some(first) = first
1627 && let Some(table) = document
1628 .get_mut(&first)
1629 .and_then(toml_edit::Item::as_table_mut)
1630 {
1631 let existing = table
1632 .decor()
1633 .prefix()
1634 .and_then(toml_edit::RawString::as_str)
1635 .unwrap_or_default()
1636 .trim_start_matches('\n')
1637 .to_owned();
1638 table
1639 .decor_mut()
1640 .set_prefix(format!("\n{carried}{existing}"));
1641 return;
1642 }
1643 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1644 if !trailing.is_empty() && !trailing.ends_with('\n') {
1645 trailing.push('\n');
1646 }
1647 trailing.push_str(carried);
1648 document.set_trailing(trailing);
1649}
1650
1651pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1657 let (name, _) = table.get_key_value(key)?;
1658 carried_comment(name.leaf_decor())
1659}
1660
1661pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1663 let Some(mut name) = table.key_mut(key) else {
1664 return;
1665 };
1666 let decor = name.leaf_decor_mut();
1667 let existing = decor
1668 .prefix()
1669 .and_then(toml_edit::RawString::as_str)
1670 .unwrap_or_default()
1671 .trim_start_matches('\n')
1672 .to_owned();
1673 decor.set_prefix(format!("\n{carried}{existing}"));
1674}
1675
1676#[must_use]
1678pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1679 known
1680 .iter()
1681 .min_by_key(|name| distance(unknown, name))
1682 .copied()
1683}
1684
1685pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1688 let carried = carried_comment(table.decor())?;
1689 table.decor_mut().set_prefix("\n");
1690 Some(carried)
1691}
1692
1693pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1704 let carried = table.get_key_value(key).and_then(|(name, item)| {
1705 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1706 if let Some(value) = item.as_value()
1707 && let Some(more) = carried_comment(value.decor())
1708 {
1709 lines.push_str(&more);
1710 }
1711 (!lines.is_empty()).then_some(lines)
1712 });
1713 let removed = table.remove(key).is_some();
1714 if let Some(carried) = carried {
1715 let existing = table
1716 .decor()
1717 .prefix()
1718 .and_then(toml_edit::RawString::as_str)
1719 .unwrap_or_default()
1720 .trim_start_matches('\n')
1721 .to_owned();
1722 table
1723 .decor_mut()
1724 .set_prefix(format!("\n{carried}{existing}"));
1725 }
1726 removed
1727}
1728
1729fn apply_key(
1732 document: &mut toml_edit::DocumentMut,
1733 key: &str,
1734 value: Option<toml_edit::Value>,
1735) -> Result<bool, RkError> {
1736 let segments: Vec<&str> = key.split('.').collect();
1737 let Some((last, parents)) = segments.split_last() else {
1740 return Err(invalid(format!("{key} names no key")));
1741 };
1742 let Some(mut value) = value else {
1743 let mut item = document.as_item_mut();
1744 for segment in parents {
1745 if item.get(segment).is_none() {
1746 return Ok(false);
1747 }
1748 item = &mut item[segment];
1749 }
1750 let removed = item
1751 .as_table_mut()
1752 .is_some_and(|table| take_comments(table, last));
1753 return Ok(removed);
1754 };
1755 let mut item = document.as_item_mut();
1756 for segment in parents {
1757 if item.get(segment).is_none() {
1758 let mut table = toml_edit::Table::new();
1759 table.set_implicit(true);
1760 item[segment] = toml_edit::Item::Table(table);
1761 }
1762 item = &mut item[segment];
1763 }
1764 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1765 if same_value(old, &value) {
1766 return Ok(false);
1767 }
1768 *value.decor_mut() = old.decor().clone();
1769 } else if let Some(comment) = migrate::template_comment(&segments) {
1770 value.decor_mut().set_suffix(comment);
1776 }
1777 item[last] = toml_edit::Item::Value(value);
1778 Ok(true)
1779}
1780
1781fn same_value(old: &toml_edit::Value, new: &toml_edit::Value) -> bool {
1785 let parsed = |value: &toml_edit::Value| {
1786 format!("v = {}", value.to_string().trim()).parse::<toml::Table>()
1787 };
1788 matches!((parsed(old), parsed(new)), (Ok(old), Ok(new)) if old == new)
1789}
1790
1791#[derive(Debug, Clone, serde::Serialize)]
1793pub struct Plan {
1794 pub action: &'static str,
1796 pub changes: Vec<String>,
1798 pub content: String,
1800}
1801
1802impl Plan {
1803 pub fn new(
1808 target: &Path,
1809 params: &crate::landing::Params,
1810 existing: Option<&Config>,
1811 record: Option<&crate::landing::manifest::Manifest>,
1812 ) -> Result<Self, RkError> {
1813 let text = existing
1814 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1815 .transpose()?;
1816 Self::compose(text.as_deref(), params, existing, record)
1817 }
1818
1819 pub fn compose(
1826 text: Option<&str>,
1827 params: &crate::landing::Params,
1828 existing: Option<&Config>,
1829 record: Option<&crate::landing::manifest::Manifest>,
1830 ) -> Result<Self, RkError> {
1831 let mut resolved = existing.cloned().unwrap_or_default();
1832 resolved.schema_version = SCHEMA_VERSION;
1833 params.repo().clone_into(&mut resolved.project.repo);
1834 resolved.profile = Profile {
1835 technologies: Some(params.technologies().to_vec()),
1836 forge: Some(params.forge().unwrap_or_default().to_owned()),
1837 release: Release {
1838 mode: Some(params.release_mode()),
1839 driver: params.driver().map(str::to_owned),
1840 style: params.style(),
1841 line_prefix: params.profile().release.line_prefix.clone(),
1842 },
1843 };
1844 resolved.git = Git {
1845 trunk: Some(params.trunk().to_owned()),
1846 checkout_mode: Some(params.checkout_mode()),
1847 integration: Some(params.integration()),
1848 merge_form: Some(params.merge_form()),
1849 };
1850 resolved.protection = with_merge_form(
1851 &protection_for(
1852 &resolved.protection,
1853 params.integration(),
1854 existing.is_none(),
1855 ),
1856 params.merge_form(),
1857 );
1858 resolved.capabilities = Capabilities {
1859 nix_packaging: Some(params.nix_packaging()),
1860 reporting_policy: Some(params.reporting_policy()),
1861 scorecard: Some(params.scorecard()),
1862 code_scanning: Some(
1863 params
1864 .code_scanning()
1865 .map_or("off", crate::landing::Provider::as_str)
1866 .to_owned(),
1867 ),
1868 workflow_guards: Some(params.workflow_guards()),
1869 };
1870 if params.release_mode() != crate::profile::ReleaseMode::None || params.reporting_policy() {
1874 resolved.security.contact = Some(params.security_contact().to_owned());
1875 resolved.security.response = Some(params.security_response().to_owned());
1876 }
1877 params
1878 .required_check()
1879 .clone_into(&mut resolved.setup.required_check);
1880 params
1881 .required_workflow()
1882 .clone_into(&mut resolved.setup.required_workflow);
1883 let bare = existing.is_some_and(Config::states_only_a_migration);
1888 let content = if let Some(text) = text.filter(|_| existing.is_some() && !bare) {
1889 rewrite_all(text, parameter_values(&resolved))?
1890 } else {
1891 let rendered =
1892 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?;
1893 with_migration(&rendered, resolved.migration.as_deref())?
1894 };
1895 parse(&content)?;
1896 Ok(Self {
1897 action: if existing.is_some() {
1898 "updated"
1899 } else {
1900 "added"
1901 },
1902 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1903 content,
1904 })
1905 }
1906
1907 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1913 write_checked(target, self.content.as_bytes())
1914 }
1915}
1916
1917#[allow(
1919 clippy::too_many_lines,
1920 reason = "one push per class P key, in the order the keys are written, and splitting it would hide that every key projects the same way"
1921)]
1922fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1923 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1924 "project.repo",
1925 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1926 )];
1927 if let Some(list) = &config.profile.technologies {
1928 values.push(("profile.technologies", Some(array(list))));
1929 }
1930 if let Some(forge) = config.profile.forge.clone() {
1931 values.push(("profile.forge", Some(forge.into())));
1932 }
1933 if let Some(mode) = config.profile.release.mode {
1934 values.push(("profile.release.mode", Some(mode.as_str().into())));
1935 values.push((
1936 "profile.release.driver",
1937 config
1938 .profile
1939 .release
1940 .driver
1941 .clone()
1942 .map(toml_edit::Value::from),
1943 ));
1944 values.push((
1945 "profile.release.style",
1946 config
1947 .profile
1948 .release
1949 .style
1950 .map(|style| style.as_str().into()),
1951 ));
1952 values.push((
1953 "profile.release.line_prefix",
1954 config
1955 .profile
1956 .release
1957 .line_prefix
1958 .clone()
1959 .map(toml_edit::Value::from),
1960 ));
1961 }
1962 if let Some(trunk) = config.git.trunk.clone() {
1963 values.push(("git.trunk", Some(trunk.into())));
1964 }
1965 if let Some(mode) = config.git.checkout_mode {
1966 values.push(("git.checkout_mode", Some(mode.as_str().into())));
1967 }
1968 if let Some(mode) = config.git.integration {
1969 values.push(("git.integration", Some(mode.as_str().into())));
1970 let mut bypass = toml_edit::Array::new();
1974 for actor in &config.protection.bypass_actors {
1975 bypass.push(actor.as_str());
1976 }
1977 values.push((
1978 "protection.bypass_actors",
1979 Some(toml_edit::Value::Array(bypass)),
1980 ));
1981 let mut rules = toml_edit::Array::new();
1982 for rule in &config.protection.owned_trunk_rules {
1983 rules.push(rule.as_str());
1984 }
1985 values.push((
1986 "protection.owned_trunk_rules",
1987 Some(toml_edit::Value::Array(rules)),
1988 ));
1989 values.push((
1990 "protection.gitlab.push_access_level",
1991 Some(config.protection.gitlab.push_access_level.into()),
1992 ));
1993 }
1994 if let Some(form) = config.git.merge_form {
1995 values.push(("git.merge_form", Some(form.as_str().into())));
1996 let mut methods = toml_edit::Array::new();
2001 for method in &config.protection.allowed_merge_methods {
2002 methods.push(method.as_str());
2003 }
2004 values.push((
2005 "protection.allowed_merge_methods",
2006 Some(toml_edit::Value::Array(methods)),
2007 ));
2008 values.push((
2009 "protection.gitlab.squash_option",
2010 Some(config.protection.gitlab.squash_option.clone().into()),
2011 ));
2012 }
2013 if let Some(value) = config.capabilities.nix_packaging {
2014 values.push(("capabilities.nix_packaging", Some(value.into())));
2015 }
2016 if let Some(value) = config.capabilities.reporting_policy {
2017 values.push(("capabilities.reporting_policy", Some(value.into())));
2018 }
2019 if let Some(value) = config.capabilities.scorecard {
2020 values.push(("capabilities.scorecard", Some(value.into())));
2021 }
2022 if let Some(value) = config.capabilities.code_scanning.clone() {
2023 values.push(("capabilities.code_scanning", Some(value.into())));
2024 }
2025 if let Some(value) = config.capabilities.workflow_guards {
2026 values.push(("capabilities.workflow_guards", Some(value.into())));
2027 }
2028 if let Some(value) = config.security.contact.clone() {
2031 values.push(("security.contact", Some(value.into())));
2032 }
2033 if let Some(value) = config.security.response.clone() {
2034 values.push(("security.response", Some(value.into())));
2035 }
2036 values.push((
2041 "setup.required_check",
2042 Some(config.setup.required_check.clone().into()),
2043 ));
2044 values.push((
2045 "setup.required_workflow",
2046 Some(config.setup.required_workflow.clone().into()),
2047 ));
2048 values
2049}
2050
2051#[must_use]
2053pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
2054 let recorded = {
2055 let params = crate::landing::Params::from_record(record);
2056 Plan::compose(None, ¶ms, None, None)
2057 .ok()
2058 .and_then(|plan| parse(&plan.content).ok())
2059 };
2060 let Some(recorded) = recorded else {
2061 return Vec::new();
2062 };
2063 let render = |value: &Option<toml_edit::Value>| {
2064 value.as_ref().map_or_else(
2065 || "<absent>".to_owned(),
2066 |value| value.to_string().trim().to_owned(),
2067 )
2068 };
2069 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
2070 .iter()
2071 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2072 .map(|(key, value)| (*key, render(value)))
2073 .collect();
2074 parameter_values(config)
2075 .into_iter()
2076 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2077 .map(|(key, value)| (key, render(&value)))
2078 .filter(|(key, value)| {
2079 baseline
2080 .iter()
2081 .any(|(other, old)| key == other && value != old)
2082 })
2083 .map(|(key, _)| key.to_owned())
2084 .collect()
2085}
2086
2087#[must_use]
2095pub(crate) fn local_protection() -> Protection {
2096 const MAINTAINER: i64 = 40;
2098 Protection {
2099 bypass_actors: vec![LOCAL_GITHUB_BYPASS.into()],
2100 gitlab: Gitlab {
2101 push_access_level: MAINTAINER,
2102 ..Gitlab::default()
2103 },
2104 ..Protection::default()
2105 }
2106}
2107
2108#[must_use]
2115pub const fn merge_values(form: MergeForm) -> (&'static str, &'static str) {
2116 match form {
2117 MergeForm::Squash => ("squash", "always"),
2118 MergeForm::Rebase => ("rebase", "never"),
2119 }
2120}
2121
2122#[must_use]
2127pub fn with_merge_form(policy: &Protection, form: MergeForm) -> Protection {
2128 let (method, squash_option) = merge_values(form);
2129 let mut next = policy.clone();
2130 next.allowed_merge_methods = vec![method.to_owned()];
2131 squash_option.clone_into(&mut next.gitlab.squash_option);
2132 next
2133}
2134
2135#[must_use]
2138pub(crate) fn legacy_local_protection(policy: &Protection) -> bool {
2139 policy.bypass_actors.is_empty()
2140 && policy.owned_trunk_rules == ["deletion", "non_fast_forward"]
2141 && policy.gitlab.push_access_level >= 40
2142}
2143
2144#[must_use]
2166fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
2167 let forge = Protection::default();
2168 let local = local_protection();
2169 let tuple = |policy: &Protection| {
2170 (
2171 policy.bypass_actors.clone(),
2172 policy.owned_trunk_rules.clone(),
2173 policy.gitlab.push_access_level,
2174 )
2175 };
2176 let taken = fresh
2177 || tuple(held) == tuple(&forge)
2178 || tuple(held) == tuple(&local)
2179 || legacy_local_protection(held);
2180 if !taken {
2181 return held.clone();
2182 }
2183 let mut next = held.clone();
2184 let source = match integration {
2185 Integration::Forge => forge,
2186 Integration::Local => local,
2187 };
2188 next.bypass_actors = source.bypass_actors;
2189 next.owned_trunk_rules = source.owned_trunk_rules;
2190 next.gitlab.push_access_level = source.gitlab.push_access_level;
2191 next
2192}
2193
2194pub fn trunk_of(target: &Path) -> Result<String, RkError> {
2199 Ok(load(target)?
2200 .and_then(|config| config.git.trunk)
2201 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
2202}
2203
2204pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
2209 Ok(load(target)?
2210 .and_then(|config| config.profile.release.line_prefix)
2211 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
2212}
2213
2214#[cfg(test)]
2215mod tests {
2216 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, set_migration, trunk_of, write};
2217 use crate::landing::{CheckoutMode, Integration, MergeForm, Style};
2218 use crate::profile::ReleaseMode;
2219
2220 fn resolved_defaults() -> Config {
2223 Config {
2224 project: super::Project {
2225 repo: "acme/widget".into(),
2226 },
2227 profile: super::Profile {
2228 technologies: Some(vec!["rust".into()]),
2229 forge: Some("github".into()),
2230 release: super::Release {
2231 mode: Some(ReleaseMode::Automatic),
2232 driver: Some("rust".into()),
2233 style: Some(Style::Trunk),
2234 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
2235 },
2236 },
2237 git: super::Git {
2238 trunk: Some(super::TRUNK_DEFAULT.into()),
2239 checkout_mode: Some(CheckoutMode::LinkedWorktree),
2240 integration: Some(Integration::Local),
2241 merge_form: Some(MergeForm::Squash),
2242 },
2243 capabilities: super::Capabilities {
2244 nix_packaging: Some(false),
2245 reporting_policy: Some(true),
2246 scorecard: Some(false),
2247 code_scanning: Some("off".to_owned()),
2248 workflow_guards: Some(false),
2249 },
2250 security: super::Security {
2253 contact: Some(String::new()),
2254 response: Some(super::RESPONSE_DEFAULT.into()),
2255 ..super::Security::default()
2256 },
2257 protection: super::Protection {
2260 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
2261 safety_ruleset: Some(format!("{}-safety", super::TRUNK_DEFAULT)),
2262 ..super::local_protection()
2263 },
2264 ..Config::default()
2265 }
2266 }
2267
2268 #[test]
2271 fn a_symlinked_configuration_path_refuses_every_writer() {
2272 let outside = tempfile::tempdir().expect("an outside directory exists");
2273 let text = "schema_version = 2\n";
2274 std::fs::write(outside.path().join("config.toml"), text).expect("writes");
2275 let linked_dir = tempfile::tempdir().expect("a tempdir");
2276 std::os::unix::fs::symlink(outside.path(), linked_dir.path().join(".release-kit"))
2277 .expect("links the directory");
2278 let linked_file = tempfile::tempdir().expect("a tempdir");
2279 std::fs::create_dir_all(linked_file.path().join(".release-kit")).expect("mkdir");
2280 std::os::unix::fs::symlink(
2281 outside.path().join("config.toml"),
2282 linked_file.path().join(CONFIG_PATH),
2283 )
2284 .expect("links the file");
2285 for dir in [&linked_dir, &linked_file] {
2286 let target = dir.path();
2287 let refusals = [
2288 write(target, &resolved_defaults()).expect_err("write refuses"),
2289 rewrite_key(target, "git.trunk", "main".into()).expect_err("rewrite refuses"),
2290 set_migration(target, Some("20261009T140205Z")).expect_err("the key refuses"),
2291 ];
2292 for error in refusals {
2293 assert_eq!(error.exit_code(), 73, "{error}");
2294 assert!(error.to_string().contains("symlink"), "{error}");
2295 }
2296 }
2297 assert_eq!(
2298 std::fs::read_to_string(outside.path().join("config.toml")).expect("reads"),
2299 text
2300 );
2301 }
2302
2303 #[test]
2304 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
2305 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
2306 let explicit = parse(
2307 "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",
2308 )
2309 .expect("explicit defaults parse");
2310 assert_eq!(omitted.git, super::Git::default());
2311 assert_eq!(omitted.capabilities, super::Capabilities::default());
2312 assert_eq!(
2313 explicit.git.checkout_mode,
2314 Some(CheckoutMode::LinkedWorktree)
2315 );
2316 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
2317 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
2318 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
2319 assert_eq!(explicit.capabilities.scorecard, Some(false));
2320 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
2321 assert_ne!(omitted, explicit);
2322 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
2324 .expect("the older spelling reads");
2325 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
2326 }
2327
2328 #[test]
2332 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
2333 let dir = tempfile::tempdir().expect("a tempdir");
2334 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
2335 std::fs::write(
2336 dir.path().join(CONFIG_PATH),
2337 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
2338 )
2339 .expect("the config writes");
2340 let held = load(dir.path())
2341 .expect("the config reads")
2342 .expect("it is present");
2343 assert_eq!(held.setup.bot.app_id, "123");
2344 assert_eq!(
2345 held.setup.bot.installation_id,
2346 Some(0),
2347 "the key parses; nothing reads it"
2348 );
2349 }
2350
2351 #[test]
2353 fn a_schema_1_config_migrates_into_its_domains() {
2354 let held = parse(
2355 "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",
2356 )
2357 .expect("a schema 1 file reads");
2358 assert_eq!(held.schema_version, 2);
2359 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
2360 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
2361 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
2362 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
2363 assert_eq!(held.profile.release.style, Some(Style::Lines));
2364 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
2365 assert_eq!(held.git.trunk.as_deref(), Some("main"));
2366 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
2367 assert_eq!(held.capabilities.nix_packaging, Some(false));
2368 assert_eq!(held.capabilities.reporting_policy, Some(true));
2369 }
2370
2371 #[test]
2373 fn every_invalid_release_state_names_its_key() {
2374 for (text, key) in [
2375 (
2376 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
2377 "profile.release.style",
2378 ),
2379 (
2380 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
2381 "profile.release.driver",
2382 ),
2383 (
2384 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
2385 "profile.release.line_prefix",
2386 ),
2387 (
2388 "[profile]\ntechnologies = ['rust', 'rust']\n",
2389 "profile.technologies",
2390 ),
2391 (
2392 "[profile]\ntechnologies = ['Rust!']\n",
2393 "profile.technologies",
2394 ),
2395 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
2396 (
2397 "[profile.release]\nmode = 'manual'\n",
2398 "profile.release.mode",
2399 ),
2400 ] {
2401 let error = parse(&format!("schema_version = 2\n{text}"))
2402 .expect_err("an invalid release state refuses")
2403 .to_string();
2404 assert!(error.contains(key), "{key}: {error}");
2405 }
2406 }
2407
2408 #[test]
2412 fn a_newly_written_key_takes_the_templates_comment() {
2413 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
2414 let next = super::rewrite_text(
2415 text,
2416 "git.integration",
2417 Some(toml_edit::Value::from("forge")),
2418 )
2419 .expect("the key writes");
2420 assert!(
2421 next.contains("integration = \"forge\" # P: local or forge"),
2422 "{next}"
2423 );
2424 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
2426 .expect("the key writes");
2427 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
2428 }
2429
2430 #[test]
2431 fn the_landed_config_template_round_trips() {
2432 let dir = tempfile::tempdir().expect("a target exists");
2433 let mut config = Config::default();
2434 config.project.repo = "acme/nested/widget".into();
2435 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
2436 config.profile.forge = Some("gitlab".into());
2437 config.profile.release = super::Release {
2438 mode: Some(ReleaseMode::Automatic),
2439 driver: Some("bash".into()),
2440 style: Some(Style::Lines),
2441 line_prefix: Some("stable/".into()),
2442 };
2443 config.git.trunk = Some("main".into());
2444 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
2445 config.git.integration = Some(Integration::Forge);
2446 config.git.merge_form = Some(MergeForm::Rebase);
2447 config.protection = super::with_merge_form(&config.protection, MergeForm::Rebase);
2448 config.capabilities.nix_packaging = Some(true);
2449 config.capabilities.reporting_policy = Some(false);
2450 config.capabilities.scorecard = Some(true);
2451 config.capabilities.code_scanning = Some("semgrep".to_owned());
2452 config.capabilities.workflow_guards = Some(true);
2453 config.security.advisories =
2457 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
2458 config.security.contact = Some("security team, room 3 \"the vault\"".into());
2459 config.security.response = Some("14 business days".into());
2460 config.setup.required_check = "build / test".into();
2461 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
2462 config.setup.release_lines = true;
2463 config.setup.excluded_steps = [
2464 (
2465 "package-check".to_owned(),
2466 "nothing is published".to_owned(),
2467 ),
2468 (
2469 "protect-trunk".to_owned(),
2470 "this project merges \"locally\"".to_owned(),
2471 ),
2472 ]
2473 .into_iter()
2474 .collect();
2475 config.setup.bot.app_id = "123".into();
2476 config.protection.trunk_ruleset = Some("primary".into());
2477 config.protection.safety_ruleset = Some("guardrail".into());
2478 config.protection.tag_ruleset = "versions".into();
2479 config.protection.lines_ruleset = "maintenance".into();
2480 config.protection.title_check = "intent".into();
2481 config.protection.tag_pattern = "refs/tags/*".into();
2482 config
2483 .protection
2484 .owned_trunk_rules
2485 .push("required_signatures".into());
2486 config.protection.required_approving_review_count = 2;
2487 config.protection.dismiss_stale_reviews_on_push = true;
2488 config.protection.require_code_owner_review = true;
2489 config.protection.require_last_push_approval = true;
2490 config.protection.gitlab.squash_commit_template =
2491 "%{title}\n\nContext: %{description}".into();
2492 config.protection.gitlab.merge_access_level = 40;
2493 let mut release_less = resolved_defaults();
2496 release_less.project.repo = String::new();
2497 release_less.profile.technologies = Some(Vec::new());
2498 release_less.profile.forge = Some(String::new());
2499 release_less.profile.release = super::Release {
2500 mode: Some(ReleaseMode::None),
2501 driver: None,
2502 style: None,
2503 line_prefix: None,
2504 };
2505 release_less.capabilities.reporting_policy = Some(false);
2506 release_less.security.contact = None;
2509 release_less.security.response = None;
2510 for expected in [resolved_defaults(), config, release_less] {
2511 write(dir.path(), &expected).expect("the template renders");
2512 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
2513 let text =
2514 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2515 assert!(text.contains("# P: every technology"));
2516 assert!(text.contains("# F: invariant"));
2517 }
2518 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2519 assert!(
2520 !text.contains("style ="),
2521 "a none release writes no style: {text}"
2522 );
2523 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
2524 }
2525
2526 #[test]
2527 fn a_config_with_an_unknown_key_refuses_by_name() {
2528 for (table, typo, nearest) in [
2529 ("", "schemax_version", "schema_version"),
2530 ("git", "trunkx", "trunk"),
2531 ("profile.release", "stile", "style"),
2532 ("capabilities", "nix_packagingg", "nix_packaging"),
2533 ("security", "contactx", "contact"),
2534 ("setup", "required_checkx", "required_check"),
2535 ("setup.bot", "app_i", "app_id"),
2536 ("protection", "trunk_rulesett", "trunk_ruleset"),
2537 (
2538 "protection.github",
2539 "squash_body_sourcex",
2540 "squash_body_source",
2541 ),
2542 ("protection.gitlab", "squash_optionx", "squash_option"),
2543 ] {
2544 let header = if table.is_empty() {
2545 String::new()
2546 } else {
2547 format!("[{table}]\n")
2548 };
2549 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
2550 let error = parse(&text).expect_err("unknown keys refuse").to_string();
2551 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
2552 assert!(error.contains(expected), "{error}");
2553 }
2554 }
2555 }
2556
2557 #[test]
2561 fn an_exclusion_names_a_real_step_and_states_why() {
2562 for (text, expected) in [
2563 (
2564 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
2565 vec!["protect-trunkk", "nearest known step: protect-trunk"],
2566 ),
2567 (
2568 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
2569 vec!["protect-trunk", "no reason"],
2570 ),
2571 ] {
2572 let error = parse(&format!("schema_version = 2\n{text}"))
2573 .expect_err("the exclusion refuses")
2574 .to_string();
2575 for want in expected {
2576 assert!(error.contains(want), "{error}");
2577 }
2578 }
2579 let held = parse(
2580 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
2581 )
2582 .expect("a named step with a reason parses");
2583 assert_eq!(
2584 held.setup
2585 .excluded_steps
2586 .get("protect-trunk")
2587 .map(String::as_str),
2588 Some("we merge locally")
2589 );
2590 }
2591
2592 #[test]
2596 fn an_exclusion_does_not_lift_a_floor() {
2597 let error = parse(
2598 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
2599 )
2600 .expect_err("the floor binds an excluded step's keys too")
2601 .to_string();
2602 assert!(
2603 error.contains("protection.allowed_merge_methods"),
2604 "{error}"
2605 );
2606 }
2607
2608 #[test]
2609 fn a_config_at_an_unknown_schema_refuses() {
2610 for text in ["schema_version = 999", "schema_version = '2'", ""] {
2611 let error = parse(text)
2612 .expect_err("a schema must be declared and known")
2613 .to_string();
2614 assert!(
2615 error.contains(CONFIG_PATH) && error.contains("schema_version"),
2616 "{error}"
2617 );
2618 }
2619 }
2620
2621 #[test]
2622 fn an_unparsable_config_refuses_naming_the_position() {
2623 let error = parse("schema_version = 2\n[project\n")
2624 .expect_err("bad TOML refuses")
2625 .to_string();
2626 for expected in [CONFIG_PATH, "line 2", "column"] {
2627 assert!(error.contains(expected), "{error}");
2628 }
2629 }
2630
2631 #[test]
2632 fn an_absent_config_reads_as_none() {
2633 let dir = tempfile::tempdir().expect("a target exists");
2634 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2635 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2636 }
2637
2638 #[test]
2639 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2640 let dir = tempfile::tempdir().expect("a target exists");
2641 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2642 std::fs::write(
2643 dir.path().join(CONFIG_PATH),
2644 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2645 )
2646 .expect("a config exists");
2647 let error =
2648 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2649 assert_eq!(error.exit_code(), 73);
2650 assert!(
2651 error
2652 .to_string()
2653 .contains("protection.strict_required_status_checks")
2654 );
2655 }
2656
2657 #[test]
2660 fn an_equal_value_keeps_its_spelling() {
2661 let original = "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'tr\\u0075nk' # ours\n";
2662 let original = original.replace("'tr\\u0075nk'", "\"tr\\u0075nk\"");
2663 let text = super::rewrite_text(&original, "profile.release.style", Some("trunk".into()))
2664 .expect("the style rewrites");
2665 assert_eq!(text, original, "an equal value is rewritten");
2666 let quoted =
2667 "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk' # ours\n";
2668 let text = super::rewrite_text(quoted, "profile.release.style", Some("trunk".into()))
2669 .expect("the style rewrites");
2670 assert_eq!(
2671 text, quoted,
2672 "a literal string equal to the value is rewritten"
2673 );
2674 }
2675
2676 #[test]
2677 fn rewrite_key_preserves_comments() {
2678 let dir = tempfile::tempdir().expect("a target exists");
2679 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2680 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";
2681 let path = dir.path().join(CONFIG_PATH);
2682 std::fs::write(&path, original).expect("a config exists");
2683 rewrite_key(dir.path(), "profile.release.style", "lines".into())
2684 .expect("the style writes back");
2685 let text = std::fs::read_to_string(&path).expect("the text reads");
2686 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2687 assert_eq!(
2688 load(dir.path())
2689 .expect("the config reads")
2690 .expect("present")
2691 .profile
2692 .release
2693 .style,
2694 Some(Style::Lines)
2695 );
2696 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2697 .expect("an omitted table can be added");
2698 assert_eq!(
2699 load(dir.path())
2700 .expect("reads")
2701 .expect("present")
2702 .project
2703 .repo,
2704 "acme/widget"
2705 );
2706 rewrite_key(
2707 dir.path(),
2708 "security.contact",
2709 "security@acme.example".into(),
2710 )
2711 .expect("the contact is a landing parameter");
2712 rewrite_key(dir.path(), "security.response", "14 days".into())
2713 .expect("the response is a landing parameter");
2714 let held = load(dir.path()).expect("reads").expect("present");
2715 assert_eq!(
2716 held.security.contact.as_deref(),
2717 Some("security@acme.example")
2718 );
2719 assert_eq!(held.security.response.as_deref(), Some("14 days"));
2720 let text = std::fs::read_to_string(&path).expect("the text reads");
2721 assert!(text.contains("# keep me"), "the comment survives: {text}");
2722 let before = std::fs::read(&path).expect("the bytes read");
2723 for (key, value) in [
2724 ("security.advisories", "acme/private"),
2725 ("security.response", "90d"),
2726 ("profile.release.style", "unknown"),
2727 ] {
2728 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2729 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2730 }
2731 }
2732
2733 #[test]
2736 fn the_security_answers_are_held_to_their_grammar() {
2737 for value in ["team@acme.example", " https://acme.example/report ", ""] {
2738 super::canonical_contact(value).expect("a control-free line is a contact");
2739 }
2740 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2741 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2742 assert!(refusal.contains("security.contact"), "{refusal}");
2743 }
2744 assert_eq!(
2745 super::canonical_contact(" team@acme.example "),
2746 Ok("team@acme.example".to_owned()),
2747 "surrounding whitespace is trimmed"
2748 );
2749 for value in [
2750 "best-effort",
2751 "1 day",
2752 "2 days",
2753 "14 days",
2754 "1 business day",
2755 "14 business days",
2756 ] {
2757 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2758 }
2759 assert_eq!(
2760 super::canonical_response(""),
2761 Ok(super::RESPONSE_DEFAULT.to_owned()),
2762 "an empty answer reads as the compiled default"
2763 );
2764 for value in [
2765 "0 days",
2766 "1 days",
2767 "2 day",
2768 "+2 days",
2769 "02 days",
2770 "4294967296 days",
2771 "90d",
2772 "two days",
2773 "we answer quickly",
2774 "2 weeks",
2775 ] {
2776 let refusal =
2777 super::canonical_response(value).expect_err("an unstateable window refuses");
2778 assert!(refusal.contains("security.response"), "{value}: {refusal}");
2779 assert!(refusal.contains("business days"), "{value}: {refusal}");
2780 }
2781 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2782 .expect_err("the reader refuses it too")
2783 .to_string();
2784 assert!(refusal.contains("security.response"), "{refusal}");
2785 }
2786
2787 #[test]
2791 fn a_migration_key_that_is_no_identifier_refuses() {
2792 for id in ["20261009T140205Z", "20261009T140205Z-2"] {
2793 let config = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2794 .expect("an identifier parses");
2795 assert_eq!(config.migration.as_deref(), Some(id));
2796 }
2797 for id in [
2798 "../../elsewhere",
2799 "20261009T140205Z-1",
2800 "",
2801 "20261009T140205\u{e9}",
2802 "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
2803 ] {
2804 let refusal = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2805 .expect_err("a value that is no identifier refuses")
2806 .to_string();
2807 assert!(
2808 refusal.contains("no migration identifier"),
2809 "{id}: {refusal}"
2810 );
2811 }
2812 }
2813
2814 #[test]
2819 fn a_pruned_header_leaves_no_comment_behind() {
2820 let cases = [
2821 (
2822 "a middle table, comment above",
2823 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2824 ),
2825 (
2826 "a middle table, comment inline",
2827 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2828 ),
2829 (
2830 "the last table, comment above",
2831 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
2832 ),
2833 (
2834 "the last table, comment inline",
2835 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
2836 ),
2837 ];
2838 for (case, text) in cases {
2839 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2840 assert!(!next.contains("repo ="), "{case}: {next}");
2841 assert!(
2842 !next.contains("[project]"),
2843 "{case}: a table every one of whose keys dropped goes with them: {next}"
2844 );
2845 assert!(
2846 next.contains("# the operator's note"),
2847 "{case}: the comment the header carried survives: {next}"
2848 );
2849 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2850 }
2851 }
2852
2853 #[test]
2859 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2860 let text = concat!(
2861 "schema_version = 2\n\n[project]\n",
2862 "# the operator's note\n",
2863 "repo = \"acme/widget\" # P: project path on the forge\n\n",
2864 "[git]\ntrunk = \"main\"\n"
2865 );
2866 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2867 assert!(!next.contains("repo ="), "{next}");
2868 assert!(!next.contains("[project]"), "{next}");
2869 assert!(
2870 next.contains("# the operator's note"),
2871 "authored text survives: {next}"
2872 );
2873 assert!(
2874 !next.contains("# P:"),
2875 "the template's comment describes a key that is gone: {next}"
2876 );
2877 parse(&next).expect("the result parses");
2878
2879 let text = concat!(
2881 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2882 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2883 "# why this project names its own prefix\n",
2884 "line_prefix = \"stable/\"\n"
2885 );
2886 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2887 .expect("the key removes");
2888 assert!(!next.contains("line_prefix ="), "{next}");
2889 assert!(next.contains("[profile.release]"), "{next}");
2890 assert!(
2891 next.contains("# why this project names its own prefix"),
2892 "authored text survives: {next}"
2893 );
2894 }
2895}