1pub mod dev_environment;
8pub mod floors;
9pub mod migrate;
10
11use std::collections::BTreeMap;
12use std::fmt::Write as _;
13use std::path::Path;
14
15use crate::diagnostic::{Diagnostic, Reason};
16use crate::error::RkError;
17use crate::landing::{CheckoutMode, Integration, MergeForm, Style};
18use crate::profile::ReleaseMode;
19use serde::{Deserialize, Serialize};
20
21pub const CONFIG_PATH: &str = ".release-kit/config.toml";
23pub const SCHEMA_VERSION: i64 = 2;
26
27#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
29#[serde(deny_unknown_fields, default)]
30pub struct Config {
31 pub schema_version: i64,
33 pub migration: Option<String>,
37 pub project: Project,
39 pub profile: Profile,
41 pub git: Git,
43 pub capabilities: Capabilities,
45 pub security: Security,
47 pub setup: Setup,
49 pub protection: Protection,
51 pub acquisition: Option<Acquisition>,
53 pub dev_environment: Option<dev_environment::DevEnvironment>,
55}
56
57impl Default for Config {
58 fn default() -> Self {
59 Self {
60 schema_version: SCHEMA_VERSION,
61 migration: None,
62 project: Project::default(),
63 profile: Profile::default(),
64 git: Git::default(),
65 capabilities: Capabilities::default(),
66 security: Security::default(),
67 setup: Setup::default(),
68 protection: Protection::default(),
69 acquisition: None,
70 dev_environment: None,
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum AcquisitionSource {
79 Host,
81}
82
83impl AcquisitionSource {
84 #[must_use]
86 pub const fn as_str(self) -> &'static str {
87 match self {
88 Self::Host => "host",
89 }
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
101#[serde(deny_unknown_fields)]
102pub struct Acquisition {
103 pub source: AcquisitionSource,
105 pub minimum: String,
107}
108
109impl Acquisition {
110 #[must_use]
112 pub fn host_at_this_release() -> Self {
113 Self {
114 source: AcquisitionSource::Host,
115 minimum: env!("CARGO_PKG_VERSION").to_owned(),
116 }
117 }
118}
119
120fn is_release_version(value: &str) -> bool {
123 let core = value.split(['-', '+']).next().unwrap_or_default();
124 let parts: Vec<&str> = core.split('.').collect();
125 parts.len() == 3
126 && parts
127 .iter()
128 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
129 && value[core.len()..]
130 .chars()
131 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
136#[serde(deny_unknown_fields, default)]
137pub struct Project {
138 pub repo: String,
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
145#[serde(deny_unknown_fields, default)]
146pub struct Profile {
147 pub technologies: Option<Vec<String>>,
149 pub forge: Option<String>,
152 pub release: Release,
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
158#[serde(deny_unknown_fields, default)]
159pub struct Release {
160 pub mode: Option<ReleaseMode>,
163 pub driver: Option<String>,
166 pub style: Option<Style>,
168 pub line_prefix: Option<String>,
170 pub registry: Option<crate::profile::RegistryAnswer>,
173}
174
175#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
177#[serde(deny_unknown_fields, default)]
178pub struct Git {
179 pub trunk: Option<String>,
183 pub checkout_mode: Option<CheckoutMode>,
185 pub integration: Option<Integration>,
188 pub merge_form: Option<MergeForm>,
190}
191
192#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
194#[serde(deny_unknown_fields, default)]
195pub struct Capabilities {
196 pub nix_packaging: Option<bool>,
198 pub reporting_policy: Option<bool>,
200 pub scorecard: Option<bool>,
202 pub code_scanning: Option<String>,
206 pub workflow_guards: Option<bool>,
209}
210
211pub const TRUNK_DEFAULT: &str = "master";
213
214pub const LINE_PREFIX_DEFAULT: &str = "release/";
216
217#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
219#[serde(deny_unknown_fields, default)]
220pub struct Security {
221 pub advisories: String,
223 pub contact: Option<String>,
228 pub response: Option<String>,
231}
232
233pub const RESPONSE_DEFAULT: &str = "best-effort";
236
237pub const LOCAL_GITHUB_BYPASS: &str = "repository-admin";
243
244pub fn canonical_contact(raw: &str) -> Result<String, String> {
254 let trimmed = raw.trim();
255 if trimmed.chars().any(char::is_control) {
256 return Err(format!(
257 "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:?}"
258 ));
259 }
260 Ok(trimmed.to_owned())
261}
262
263pub fn canonical_response(raw: &str) -> Result<String, String> {
275 let trimmed = raw.trim();
276 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
277 return Ok(RESPONSE_DEFAULT.to_owned());
278 }
279 let refusal = || {
280 format!(
281 "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:?}"
282 )
283 };
284 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
285 let plural = match unit {
286 "day" | "business day" => false,
287 "days" | "business days" => true,
288 _ => return Err(refusal()),
289 };
290 let number: u32 = count.parse().map_err(|_| refusal())?;
291 if count != number.to_string() || (number > 1) != plural {
294 return Err(refusal());
295 }
296 Ok(trimmed.to_owned())
297}
298
299#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
301#[serde(deny_unknown_fields, default)]
302pub struct Setup {
303 pub required_check: String,
306 pub required_workflow: String,
310 pub retired_branches: Vec<String>,
312 pub release_lines: bool,
314 pub excluded_steps: BTreeMap<String, String>,
319 pub bot: Bot,
321}
322
323impl Default for Setup {
324 fn default() -> Self {
325 Self {
326 required_check: String::new(),
327 required_workflow: String::new(),
328 retired_branches: vec!["main".into(), "develop".into()],
329 release_lines: false,
330 excluded_steps: BTreeMap::new(),
331 bot: Bot::default(),
332 }
333 }
334}
335
336#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
343#[serde(deny_unknown_fields, default)]
344pub struct Bot {
345 pub app_id: String,
347 #[serde(default, skip_serializing)]
352 pub installation_id: Option<i64>,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
357#[serde(deny_unknown_fields, default)]
358#[allow(
359 clippy::struct_excessive_bools,
360 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
361)]
362pub struct Protection {
363 pub trunk_ruleset: Option<String>,
367 pub safety_ruleset: Option<String>,
372 pub tag_ruleset: String,
374 pub lines_ruleset: String,
376 pub title_check: String,
378 pub tag_pattern: String,
380 pub bypass_actors: Vec<String>,
383 pub allowed_merge_methods: Vec<String>,
386 pub strict_required_status_checks: bool,
388 pub owned_trunk_rules: Vec<String>,
392 pub required_approving_review_count: i64,
394 pub dismiss_stale_reviews_on_push: bool,
396 pub require_code_owner_review: bool,
398 pub require_last_push_approval: bool,
400 pub github: Github,
402 pub gitlab: Gitlab,
404}
405
406impl Default for Protection {
407 fn default() -> Self {
408 Self {
409 trunk_ruleset: None,
410 safety_ruleset: None,
411 tag_ruleset: "release-tags".into(),
412 lines_ruleset: "release-lines".into(),
413 title_check: "pr-title".into(),
414 tag_pattern: "refs/tags/v*".into(),
415 bypass_actors: Vec::new(),
416 allowed_merge_methods: vec!["squash".into()],
417 strict_required_status_checks: true,
418 owned_trunk_rules: vec![
419 "deletion".into(),
420 "non_fast_forward".into(),
421 "pull_request".into(),
422 "required_status_checks".into(),
423 ],
424 required_approving_review_count: 0,
425 dismiss_stale_reviews_on_push: false,
426 require_code_owner_review: false,
427 require_last_push_approval: false,
428 github: Github::default(),
429 gitlab: Gitlab::default(),
430 }
431 }
432}
433
434impl Protection {
435 #[must_use]
438 pub fn trunk_ruleset(&self, trunk: &str) -> String {
439 self.trunk_ruleset
440 .clone()
441 .unwrap_or_else(|| format!("{trunk}-protection"))
442 }
443
444 #[must_use]
451 pub fn safety_ruleset(&self, trunk: &str) -> String {
452 self.safety_ruleset
453 .clone()
454 .unwrap_or_else(|| format!("{trunk}-safety"))
455 }
456}
457
458pub const SAFETY_RULES: [&str; 3] = ["deletion", "non_fast_forward", LINEAR_HISTORY_RULE];
468
469pub const COMMIT_GATE_CHECK: &str = "pr-commits";
473
474pub const LINEAR_HISTORY_RULE: &str = "required_linear_history";
478
479pub const REQUEST_RULES: [&str; 2] = ["pull_request", "required_status_checks"];
482
483#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
485#[serde(deny_unknown_fields, default)]
486pub struct Github {
487 pub squash_title_source: String,
490 pub squash_body_source: String,
492}
493
494impl Default for Github {
495 fn default() -> Self {
496 Self {
497 squash_title_source: "PR_TITLE".into(),
498 squash_body_source: "PR_BODY".into(),
499 }
500 }
501}
502
503#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
505#[serde(deny_unknown_fields, default)]
506pub struct Gitlab {
507 pub merge_method: String,
509 pub squash_option: String,
513 pub squash_commit_template: String,
515 pub push_access_level: i64,
520 pub merge_access_level: i64,
522}
523
524impl Default for Gitlab {
525 fn default() -> Self {
526 Self {
527 merge_method: "ff".into(),
528 squash_option: "always".into(),
529 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
530 .trim_end_matches('\n')
531 .to_owned(),
532 push_access_level: 0,
533 merge_access_level: 40,
534 }
535 }
536}
537
538pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
543 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
544 Ok(text) => text,
545 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
546 Err(error) => return Err(error.into()),
547 };
548 parse(&text).map(Some)
549}
550
551fn current_text(text: &str) -> Result<String, RkError> {
554 let raw: toml::Value =
555 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
556 match raw.get("schema_version").and_then(toml::Value::as_integer) {
557 Some(1) => migrate::to_schema_2(text),
558 _ => Ok(text.to_owned()),
559 }
560}
561
562fn release_vocabulary(raw: &toml::Value) -> Result<(), RkError> {
568 let release = raw
569 .get("profile")
570 .and_then(|profile| profile.get("release"));
571 if let Some(mode) = release.and_then(|release| release.get("mode")) {
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 match release.and_then(|release| release.get("registry")) {
579 Some(registry) if !matches!(registry.as_str(), Some("crates-io" | "none")) => {
580 Err(invalid(format!(
581 "profile.release.registry is {registry}; it takes crates-io or none, where the crate publishes, for an automatic rust release alone"
582 )))
583 }
584 _ => Ok(()),
585 }
586}
587
588fn registry_fit(release: &Release) -> Result<(), RkError> {
591 if release.registry.is_some()
592 && (release.driver.is_some()
593 || matches!(
594 release.mode,
595 Some(ReleaseMode::External | ReleaseMode::None)
596 ))
597 {
598 crate::profile::catalog::registry_answer_fits(
599 release.mode.unwrap_or(ReleaseMode::Automatic),
600 release.driver.as_deref(),
601 )
602 .map_err(invalid)?;
603 }
604 Ok(())
605}
606
607fn parse(text: &str) -> Result<Config, RkError> {
608 let text = current_text(text)?;
609 let raw: toml::Value =
610 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
611 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
612 return Err(invalid(format!(
613 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
614 )));
615 }
616 release_vocabulary(&raw)?;
617 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
618 let mut message = error.to_string();
619 if let Some(rest) = error.message().strip_prefix("unknown field `") {
620 let names: Vec<_> = rest.split('`').collect();
621 if let Some(unknown) = names.first()
622 && let Some(nearest) = names
623 .iter()
624 .skip(2)
625 .step_by(2)
626 .min_by_key(|name| distance(unknown, name))
627 {
628 let _ = write!(message, "; nearest known key: {nearest}");
629 }
630 }
631 invalid(message)
632 })?;
633 if let Some(technologies) = &config.profile.technologies {
634 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
635 }
636 if let Some(forge) = &config.profile.forge
637 && !forge.is_empty()
638 {
639 crate::profile::canonical_category(forge)
640 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
641 }
642 if let Some(driver) = &config.profile.release.driver {
643 crate::profile::canonical_category(driver)
644 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
645 }
646 if matches!(
647 config.profile.release.mode,
648 Some(ReleaseMode::External | ReleaseMode::None)
649 ) {
650 let mode = config
651 .profile
652 .release
653 .mode
654 .map_or("none", ReleaseMode::as_str);
655 for (key, present) in [
656 (
657 "profile.release.driver",
658 config.profile.release.driver.is_some(),
659 ),
660 (
661 "profile.release.style",
662 config.profile.release.style.is_some(),
663 ),
664 (
665 "profile.release.line_prefix",
666 config.profile.release.line_prefix.is_some(),
667 ),
668 ] {
669 if present {
670 return Err(invalid(format!(
671 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
672 )));
673 }
674 }
675 }
676 registry_fit(&config.profile.release)?;
677 if let Some(contact) = &config.security.contact {
678 canonical_contact(contact).map_err(invalid)?;
679 }
680 if let Some(response) = &config.security.response {
681 canonical_response(response).map_err(invalid)?;
682 }
683 exclusions(&config.setup.excluded_steps)?;
684 if let Some(acquisition) = &config.acquisition
685 && !is_release_version(&acquisition.minimum)
686 {
687 return Err(invalid(format!(
688 "acquisition.minimum is {}, which is no release version; write one such as {}",
689 acquisition.minimum,
690 env!("CARGO_PKG_VERSION")
691 )));
692 }
693 if let Some(answer) = &config.dev_environment {
694 answer.check().map_err(invalid)?;
695 }
696 migration_key(&config)?;
697 floors::check(&config)?;
698 Ok(config)
699}
700
701fn migration_key(config: &Config) -> Result<(), RkError> {
704 match &config.migration {
705 Some(id) if !crate::migrate::is_id(id) => Err(invalid(format!(
706 "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)"
707 ))),
708 _ => Ok(()),
709 }
710}
711
712fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
719 for (name, reason) in excluded {
720 if crate::setup::steps::spec(name).is_none() {
721 let nearest = crate::setup::steps::STEPS
722 .iter()
723 .min_by_key(|step| distance(name, step.name))
724 .map_or("", |step| step.name);
725 return Err(invalid(format!(
726 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
727 )));
728 }
729 if reason.trim().is_empty() {
730 return Err(invalid(format!(
731 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
732 )));
733 }
734 }
735 Ok(())
736}
737
738pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
739 RkError::refusal(
740 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
741 .action(format!("edit {CONFIG_PATH} and retry"))
742 .target_state("nothing was written"),
743 )
744}
745
746fn distance(left: &str, right: &str) -> usize {
747 let mut row: Vec<_> = (0..=right.chars().count()).collect();
748 for (i, a) in left.chars().enumerate() {
749 let mut previous = row[0];
750 row[0] = i + 1;
751 for (j, b) in right.chars().enumerate() {
752 let old = row[j + 1];
753 row[j + 1] = (previous + usize::from(a != b))
754 .min(row[j] + 1)
755 .min(old + 1);
756 previous = old;
757 }
758 }
759 row.last().copied().unwrap_or(0)
760}
761
762pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
768 let bytes = render(config)?;
769 parse(&String::from_utf8_lossy(&bytes))?;
770 write_checked(target, &bytes)
771}
772
773fn write_checked(target: &Path, bytes: &[u8]) -> Result<(), RkError> {
776 check_writable(target)?;
777 crate::atomic::write(&target.join(CONFIG_PATH), bytes)?;
778 Ok(())
779}
780
781fn array(values: &[String]) -> toml_edit::Value {
782 toml_edit::Value::Array(values.iter().collect())
783}
784
785fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
790 let mut table = toml_edit::InlineTable::new();
791 for (key, value) in values {
792 table.insert(key, value.clone().into());
793 }
794 toml_edit::Value::InlineTable(table)
795}
796
797#[allow(
802 clippy::too_many_lines,
803 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
804)]
805fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
806 let trunk = config
810 .git
811 .trunk
812 .clone()
813 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
814 let technologies = config
815 .profile
816 .technologies
817 .clone()
818 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
819 let forge = config
820 .profile
821 .forge
822 .clone()
823 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
824 let mode = config
825 .profile
826 .release
827 .mode
828 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
829 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
830 (
831 "RK_CONFIG_SCHEMA_VERSION",
832 Some(config.schema_version.into()),
833 ),
834 (
835 "RK_CONFIG_PROJECT_REPO",
836 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
837 ),
838 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
839 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
840 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
841 (
842 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
843 config
844 .profile
845 .release
846 .driver
847 .clone()
848 .map(toml_edit::Value::from),
849 ),
850 (
851 "RK_CONFIG_PROFILE_RELEASE_STYLE",
852 config
853 .profile
854 .release
855 .style
856 .map(|style| style.as_str().into()),
857 ),
858 (
859 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
860 config
861 .profile
862 .release
863 .line_prefix
864 .clone()
865 .map(toml_edit::Value::from),
866 ),
867 (
868 "RK_CONFIG_PROFILE_RELEASE_REGISTRY",
869 config
870 .profile
871 .release
872 .registry
873 .map(|answer| answer.as_str().into()),
874 ),
875 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
876 (
877 "RK_CONFIG_GIT_CHECKOUT_MODE",
878 Some(
879 config
880 .git
881 .checkout_mode
882 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
883 .as_str()
884 .into(),
885 ),
886 ),
887 (
888 "RK_CONFIG_GIT_INTEGRATION",
889 Some(
890 config
891 .git
892 .integration
893 .ok_or_else(|| invalid("git.integration is unresolved"))?
894 .as_str()
895 .into(),
896 ),
897 ),
898 (
899 "RK_CONFIG_GIT_MERGE_FORM",
900 Some(
901 config
902 .git
903 .merge_form
904 .ok_or_else(|| invalid("git.merge_form is unresolved"))?
905 .as_str()
906 .into(),
907 ),
908 ),
909 (
910 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
911 Some(
912 config
913 .capabilities
914 .nix_packaging
915 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
916 .into(),
917 ),
918 ),
919 (
920 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
921 Some(
922 config
923 .capabilities
924 .reporting_policy
925 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
926 .into(),
927 ),
928 ),
929 (
930 "RK_CONFIG_CAPABILITIES_SCORECARD",
931 Some(
932 config
933 .capabilities
934 .scorecard
935 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
936 .into(),
937 ),
938 ),
939 (
940 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
941 Some(
942 config
943 .capabilities
944 .code_scanning
945 .clone()
946 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
947 .into(),
948 ),
949 ),
950 (
951 "RK_CONFIG_CAPABILITIES_WORKFLOW_GUARDS",
952 Some(
953 config
954 .capabilities
955 .workflow_guards
956 .ok_or_else(|| invalid("capabilities.workflow_guards is unresolved"))?
957 .into(),
958 ),
959 ),
960 (
961 "RK_CONFIG_SECURITY_ADVISORIES",
962 Some(config.security.advisories.clone().into()),
963 ),
964 (
965 "RK_CONFIG_SECURITY_CONTACT",
966 Some(config.security.contact.clone().unwrap_or_default().into()),
967 ),
968 (
969 "RK_CONFIG_SECURITY_RESPONSE",
970 Some(
971 config
972 .security
973 .response
974 .clone()
975 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
976 .into(),
977 ),
978 ),
979 (
980 "RK_CONFIG_SETUP_REQUIRED_CHECK",
981 Some(config.setup.required_check.clone().into()),
982 ),
983 (
984 "RK_CONFIG_SETUP_REQUIRED_WORKFLOW",
985 Some(config.setup.required_workflow.clone().into()),
986 ),
987 (
988 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
989 Some(array(&config.setup.retired_branches)),
990 ),
991 (
992 "RK_CONFIG_SETUP_RELEASE_LINES",
993 Some(config.setup.release_lines.into()),
994 ),
995 (
996 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
997 Some(inline(&config.setup.excluded_steps)),
998 ),
999 (
1000 "RK_CONFIG_SETUP_BOT_APP_ID",
1001 Some(config.setup.bot.app_id.clone().into()),
1002 ),
1003 (
1004 "RK_CONFIG_ACQUISITION_SOURCE",
1005 config
1006 .acquisition
1007 .as_ref()
1008 .map(|answer| answer.source.as_str().into()),
1009 ),
1010 (
1011 "RK_CONFIG_ACQUISITION_MINIMUM",
1012 config
1013 .acquisition
1014 .as_ref()
1015 .map(|answer| answer.minimum.clone().into()),
1016 ),
1017 ];
1018 fields.extend(
1019 protection_fields(&config.protection, trunk.as_str())
1020 .into_iter()
1021 .map(|(token, value)| (token, Some(value))),
1022 );
1023 if mode == crate::profile::ReleaseMode::None {
1029 let reporting = config.capabilities.reporting_policy.unwrap_or(false);
1030 for (token, value) in &mut fields {
1031 if RELEASE_ONLY_TOKENS.contains(token)
1032 || (!reporting && SECURITY_TOKENS.contains(token))
1033 {
1034 *value = None;
1035 }
1036 }
1037 }
1038 Ok(fields)
1039}
1040
1041const RELEASE_ONLY_TOKENS: [&str; 4] = [
1047 "RK_CONFIG_PROTECTION_TAG_RULESET",
1048 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1049 "RK_CONFIG_PROTECTION_LINES_RULESET",
1050 "RK_CONFIG_SETUP_BOT_APP_ID",
1051];
1052
1053const SECURITY_TOKENS: [&str; 3] = [
1056 "RK_CONFIG_SECURITY_ADVISORIES",
1057 "RK_CONFIG_SECURITY_CONTACT",
1058 "RK_CONFIG_SECURITY_RESPONSE",
1059];
1060
1061fn render(config: &Config) -> Result<Vec<u8>, RkError> {
1062 let fields = fields(config)?;
1063 let template = crate::embedded::BLOCKS
1064 .get_file("target-config.toml.in")
1065 .and_then(include_dir::File::contents_utf8)
1066 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
1067 let lines: Vec<&str> = template.split_inclusive('\n').collect();
1073 let dropped: Vec<bool> = lines
1074 .iter()
1075 .map(|line| {
1076 matches!(
1077 fields.iter().find(|(token, _)| line.contains(token)),
1078 Some((_, None))
1079 )
1080 })
1081 .collect();
1082 let mut bytes = Vec::new();
1083 let mut at = 0;
1084 while at < lines.len() {
1085 let line = lines[at];
1086 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
1087 let mut end = at + 1;
1088 let mut keeps = false;
1089 while end < lines.len()
1090 && !(lines[end].trim_start().starts_with('[')
1091 && lines[end].trim_end().ends_with(']'))
1092 {
1093 keeps |=
1094 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
1095 end += 1;
1096 }
1097 if !keeps {
1098 while bytes.last() == Some(&b'\n')
1100 && bytes.len() >= 2
1101 && bytes[bytes.len() - 2] == b'\n'
1102 {
1103 bytes.pop();
1104 }
1105 at = end;
1106 continue;
1107 }
1108 }
1109 if !dropped[at] {
1110 match fields.iter().find(|(token, _)| line.contains(token)) {
1111 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
1112 line.as_bytes(),
1113 token.as_bytes(),
1114 value.to_string().as_bytes(),
1115 )),
1116 Some((_, None)) => {}
1117 None => bytes.extend_from_slice(line.as_bytes()),
1118 }
1119 }
1120 at += 1;
1121 }
1122 Ok(bytes)
1123}
1124
1125fn protection_fields(
1126 protection: &Protection,
1127 trunk: &str,
1128) -> Vec<(&'static str, toml_edit::Value)> {
1129 vec![
1130 (
1131 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
1132 protection.trunk_ruleset(trunk).into(),
1133 ),
1134 (
1135 "RK_CONFIG_PROTECTION_SAFETY_RULESET",
1136 protection.safety_ruleset(trunk).into(),
1137 ),
1138 (
1139 "RK_CONFIG_PROTECTION_TAG_RULESET",
1140 protection.tag_ruleset.clone().into(),
1141 ),
1142 (
1143 "RK_CONFIG_PROTECTION_LINES_RULESET",
1144 protection.lines_ruleset.clone().into(),
1145 ),
1146 (
1147 "RK_CONFIG_PROTECTION_TITLE_CHECK",
1148 protection.title_check.clone().into(),
1149 ),
1150 (
1151 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1152 protection.tag_pattern.clone().into(),
1153 ),
1154 (
1155 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
1156 array(&protection.bypass_actors),
1157 ),
1158 (
1159 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
1160 array(&protection.allowed_merge_methods),
1161 ),
1162 (
1163 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
1164 protection.strict_required_status_checks.into(),
1165 ),
1166 (
1167 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
1168 array(&protection.owned_trunk_rules),
1169 ),
1170 (
1171 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
1172 protection.required_approving_review_count.into(),
1173 ),
1174 (
1175 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
1176 protection.dismiss_stale_reviews_on_push.into(),
1177 ),
1178 (
1179 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
1180 protection.require_code_owner_review.into(),
1181 ),
1182 (
1183 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
1184 protection.require_last_push_approval.into(),
1185 ),
1186 (
1187 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
1188 protection.github.squash_title_source.clone().into(),
1189 ),
1190 (
1191 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
1192 protection.github.squash_body_source.clone().into(),
1193 ),
1194 (
1195 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
1196 protection.gitlab.merge_method.clone().into(),
1197 ),
1198 (
1199 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
1200 protection.gitlab.squash_option.clone().into(),
1201 ),
1202 (
1203 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
1204 protection.gitlab.squash_commit_template.clone().into(),
1205 ),
1206 (
1207 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
1208 protection.gitlab.push_access_level.into(),
1209 ),
1210 (
1211 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
1212 protection.gitlab.merge_access_level.into(),
1213 ),
1214 ]
1215}
1216
1217const DERIVED_POLICY_KEYS: [&str; 5] = [
1228 "protection.bypass_actors",
1229 "protection.owned_trunk_rules",
1230 "protection.gitlab.push_access_level",
1231 "protection.allowed_merge_methods",
1232 "protection.gitlab.squash_option",
1233];
1234
1235const PARAMETER_KEYS: [&str; 27] = [
1237 "project.repo",
1238 "profile.technologies",
1239 "profile.forge",
1240 "profile.release.mode",
1241 "profile.release.driver",
1242 "profile.release.style",
1243 "profile.release.line_prefix",
1244 "profile.release.registry",
1245 "git.trunk",
1246 "git.checkout_mode",
1247 "git.integration",
1248 "git.merge_form",
1249 "protection.allowed_merge_methods",
1252 "protection.gitlab.squash_option",
1253 "protection.bypass_actors",
1257 "protection.owned_trunk_rules",
1258 "protection.gitlab.push_access_level",
1259 "capabilities.nix_packaging",
1260 "capabilities.reporting_policy",
1261 "capabilities.scorecard",
1262 "capabilities.code_scanning",
1263 "capabilities.workflow_guards",
1264 "security.contact",
1265 "security.response",
1266 "setup.required_check",
1269 "setup.required_workflow",
1270 "schema_version",
1271];
1272
1273pub fn with_acquisition(target: &Path, answer: &Acquisition) -> Result<(String, bool), RkError> {
1285 let path = target.join(CONFIG_PATH);
1286 let text = match std::fs::read_to_string(&path) {
1287 Ok(text) => text,
1288 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1289 Err(error) => return Err(error.into()),
1290 };
1291 if text.is_empty() {
1292 let template = crate::embedded::BLOCKS
1293 .get_file("acquisition-config.toml.in")
1294 .and_then(include_dir::File::contents_utf8)
1295 .ok_or_else(|| invalid("the binary lacks its acquisition template"))?;
1296 let mut fresh = template.as_bytes().to_vec();
1297 for (token, value) in [
1298 (
1299 "RK_CONFIG_SCHEMA_VERSION",
1300 toml_edit::Value::from(SCHEMA_VERSION),
1301 ),
1302 (
1303 "RK_CONFIG_ACQUISITION_SOURCE",
1304 toml_edit::Value::from(answer.source.as_str()),
1305 ),
1306 (
1307 "RK_CONFIG_ACQUISITION_MINIMUM",
1308 toml_edit::Value::from(answer.minimum.clone()),
1309 ),
1310 ] {
1311 fresh =
1312 crate::landing::substitute(&fresh, token.as_bytes(), value.to_string().as_bytes());
1313 }
1314 let fresh = String::from_utf8(fresh).map_err(|error| invalid(error.to_string()))?;
1315 parse(&fresh)?;
1316 return Ok((fresh, true));
1317 }
1318 let held = parse(&text)?;
1319 if held.acquisition.as_ref() == Some(answer) {
1320 return Ok((text, false));
1321 }
1322 let text = current_text(&text)?;
1323 let mut document = text
1324 .parse::<toml_edit::DocumentMut>()
1325 .map_err(|error| invalid(error.to_string()))?;
1326 if !document.contains_table("acquisition") {
1327 let mut table = toml_edit::Table::new();
1328 table.decor_mut().set_prefix("\n");
1329 document.insert("acquisition", toml_edit::Item::Table(table));
1330 }
1331 let table = document
1332 .get_mut("acquisition")
1333 .and_then(toml_edit::Item::as_table_mut)
1334 .ok_or_else(|| invalid("acquisition must be a table"))?;
1335 for (key, value, note) in [
1336 (
1337 "source",
1338 answer.source.as_str().to_owned(),
1339 " P: host where every host installs rk itself; absent where a manager file pins it",
1340 ),
1341 (
1342 "minimum",
1343 answer.minimum.clone(),
1344 " P: the lowest rk release a host may run against this project",
1345 ),
1346 ] {
1347 let kept = table
1348 .get(key)
1349 .and_then(toml_edit::Item::as_value)
1350 .map(|held| held.decor().clone());
1351 let mut value = toml_edit::Value::from(value);
1352 match kept {
1353 Some(decor) => *value.decor_mut() = decor,
1354 None => value.decor_mut().set_suffix(format!(" #{note}")),
1355 }
1356 table.insert(key, toml_edit::Item::Value(value));
1357 }
1358 let next = document.to_string();
1359 parse(&next)?;
1360 Ok((next, true))
1361}
1362
1363impl Config {
1364 #[must_use]
1367 pub fn states_only_a_migration(&self) -> bool {
1368 self.migration.is_some()
1369 && Self {
1370 migration: None,
1371 ..self.clone()
1372 } == Self::default()
1373 }
1374}
1375
1376pub fn with_migration(text: &str, id: Option<&str>) -> Result<String, RkError> {
1383 let mut document = text
1384 .parse::<toml_edit::DocumentMut>()
1385 .map_err(|error| invalid(error.to_string()))?;
1386 let root = document.as_table_mut();
1387 match id {
1388 Some(id) => {
1389 root.insert("migration", toml_edit::value(id));
1390 root.sort_values_by(|a, _, b, _| {
1392 let rank = |key: &toml_edit::Key| match key.get() {
1393 "schema_version" => 0,
1394 "migration" => 1,
1395 _ => 2,
1396 };
1397 rank(a).cmp(&rank(b))
1398 });
1399 }
1400 None => {
1401 root.remove("migration");
1402 }
1403 }
1404 let next = document.to_string();
1405 parse(&next)?;
1406 Ok(next)
1407}
1408
1409pub fn check_writable(target: &Path) -> Result<(), RkError> {
1419 let file = target.join(CONFIG_PATH);
1420 for (path, directory) in [(file.parent(), true), (Some(file.as_path()), false)] {
1421 let Some(path) = path else { continue };
1422 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1423 continue;
1424 };
1425 let kind = metadata.file_type();
1426 let fits = if directory {
1427 kind.is_dir()
1428 } else {
1429 kind.is_file()
1430 };
1431 if !fits {
1432 return Err(RkError::refusal(
1433 Diagnostic::new(
1434 Reason::StateDrift,
1435 format!(
1436 "{} is a symlink or not a regular {}, so a configuration write would not stay in the target",
1437 path.display(),
1438 if directory { "directory" } else { "file" }
1439 ),
1440 )
1441 .expected(format!("{CONFIG_PATH} as a regular file in a regular directory"))
1442 .action("replace the symlink with the directory or file it stands for")
1443 .target_state("unchanged"),
1444 ));
1445 }
1446 }
1447 Ok(())
1448}
1449
1450pub fn set_migration(target: &Path, id: Option<&str>) -> Result<(), RkError> {
1458 check_writable(target)?;
1459 let path = target.join(CONFIG_PATH);
1460 let text = match std::fs::read_to_string(&path) {
1461 Ok(text) => text,
1462 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1463 if id.is_none() {
1464 return Ok(());
1465 }
1466 format!("schema_version = {SCHEMA_VERSION}\n")
1467 }
1468 Err(error) => return Err(error.into()),
1469 };
1470 let next = with_migration(¤t_text(&text)?, id)?;
1471 write_checked(target, next.as_bytes())
1472}
1473
1474pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
1480 let path = target.join(CONFIG_PATH);
1481 let text = std::fs::read_to_string(&path)?;
1482 let next = rewrite_text(&text, key, Some(value))?;
1483 write_checked(target, next.as_bytes())
1484}
1485
1486fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
1489 rewrite_all(text, vec![(key, value)])
1490}
1491
1492fn rewrite_all(
1500 text: &str,
1501 values: Vec<(&str, Option<toml_edit::Value>)>,
1502) -> Result<String, RkError> {
1503 for (key, _) in &values {
1504 if !PARAMETER_KEYS.contains(key) {
1505 return Err(invalid(format!("{key} is not a landing parameter")));
1506 }
1507 }
1508 parse(text)?;
1509 let text = current_text(text)?;
1510 let mut document = text
1511 .parse::<toml_edit::DocumentMut>()
1512 .map_err(|error| invalid(error.to_string()))?;
1513 let mut changed = false;
1514 let mut emptied: Vec<&str> = Vec::new();
1517 for (key, value) in values {
1518 let removing = value.is_none();
1519 let applied = apply_key(&mut document, key, value)?;
1520 changed |= applied;
1521 if applied
1522 && removing
1523 && let Some(parent) = key.split('.').next()
1524 {
1525 emptied.push(parent);
1526 }
1527 }
1528 changed |= prune_empty_tables(&mut document, &emptied);
1529 changed |= migrate::refresh_comments(&mut document);
1536 if !changed {
1537 return Ok(text);
1538 }
1539 let next = document.to_string();
1540 parse(&next)?;
1541 Ok(next)
1542}
1543
1544pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1563 let order: Vec<String> = document
1564 .as_table()
1565 .iter()
1566 .map(|(key, _)| key.to_owned())
1567 .collect();
1568 let mut changed = false;
1569 for (index, name) in order.iter().enumerate() {
1570 if !names.contains(&name.as_str())
1571 || !document
1572 .get(name)
1573 .and_then(toml_edit::Item::as_table)
1574 .is_some_and(toml_edit::Table::is_empty)
1575 {
1576 continue;
1577 }
1578 let carried = document
1579 .get(name)
1580 .and_then(toml_edit::Item::as_table)
1581 .and_then(|table| carried_comment(table.decor()));
1582 document.remove(name);
1583 changed = true;
1584 let Some(carried) = carried else { continue };
1585 let next = order
1589 .iter()
1590 .skip(index + 1)
1591 .find(|name| {
1592 document
1593 .get(name)
1594 .and_then(toml_edit::Item::as_table)
1595 .is_some_and(|table| !table.is_implicit())
1596 })
1597 .cloned();
1598 if let Some(next) = next
1599 && let Some(table) = document
1600 .get_mut(&next)
1601 .and_then(toml_edit::Item::as_table_mut)
1602 {
1603 let existing = table
1604 .decor()
1605 .prefix()
1606 .and_then(toml_edit::RawString::as_str)
1607 .unwrap_or_default()
1608 .trim_start_matches('\n')
1609 .to_owned();
1610 table
1611 .decor_mut()
1612 .set_prefix(format!("\n{carried}{existing}"));
1613 } else {
1614 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1615 if !trailing.is_empty() && !trailing.ends_with('\n') {
1616 trailing.push('\n');
1617 }
1618 trailing.push_str(&carried);
1619 document.set_trailing(trailing);
1620 }
1621 }
1622 changed
1623}
1624
1625fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1632 let mut lines = String::new();
1633 for raw in [decor.prefix(), decor.suffix()] {
1634 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1635 continue;
1636 };
1637 for line in text
1638 .lines()
1639 .map(str::trim)
1640 .filter(|line| line.starts_with('#'))
1641 .filter(|line| !is_template_comment(line))
1642 {
1643 lines.push_str(line);
1644 lines.push('\n');
1645 }
1646 }
1647 (!lines.is_empty()).then_some(lines)
1648}
1649
1650fn is_template_comment(line: &str) -> bool {
1659 let rest = line.trim_start_matches('#').trim_start();
1660 ["P:", "N:", "F:"]
1661 .iter()
1662 .any(|marker| rest.starts_with(marker))
1663}
1664
1665pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1673 let first = document
1674 .as_table()
1675 .iter()
1676 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1677 .map(|(name, _)| name.to_owned());
1678 if let Some(first) = first
1679 && let Some(table) = document
1680 .get_mut(&first)
1681 .and_then(toml_edit::Item::as_table_mut)
1682 {
1683 let existing = table
1684 .decor()
1685 .prefix()
1686 .and_then(toml_edit::RawString::as_str)
1687 .unwrap_or_default()
1688 .trim_start_matches('\n')
1689 .to_owned();
1690 table
1691 .decor_mut()
1692 .set_prefix(format!("\n{carried}{existing}"));
1693 return;
1694 }
1695 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1696 if !trailing.is_empty() && !trailing.ends_with('\n') {
1697 trailing.push('\n');
1698 }
1699 trailing.push_str(carried);
1700 document.set_trailing(trailing);
1701}
1702
1703pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1709 let (name, _) = table.get_key_value(key)?;
1710 carried_comment(name.leaf_decor())
1711}
1712
1713pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1715 let Some(mut name) = table.key_mut(key) else {
1716 return;
1717 };
1718 let decor = name.leaf_decor_mut();
1719 let existing = decor
1720 .prefix()
1721 .and_then(toml_edit::RawString::as_str)
1722 .unwrap_or_default()
1723 .trim_start_matches('\n')
1724 .to_owned();
1725 decor.set_prefix(format!("\n{carried}{existing}"));
1726}
1727
1728#[must_use]
1730pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1731 known
1732 .iter()
1733 .min_by_key(|name| distance(unknown, name))
1734 .copied()
1735}
1736
1737pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1740 let carried = carried_comment(table.decor())?;
1741 table.decor_mut().set_prefix("\n");
1742 Some(carried)
1743}
1744
1745pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1756 let carried = table.get_key_value(key).and_then(|(name, item)| {
1757 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1758 if let Some(value) = item.as_value()
1759 && let Some(more) = carried_comment(value.decor())
1760 {
1761 lines.push_str(&more);
1762 }
1763 (!lines.is_empty()).then_some(lines)
1764 });
1765 let removed = table.remove(key).is_some();
1766 if let Some(carried) = carried {
1767 let existing = table
1768 .decor()
1769 .prefix()
1770 .and_then(toml_edit::RawString::as_str)
1771 .unwrap_or_default()
1772 .trim_start_matches('\n')
1773 .to_owned();
1774 table
1775 .decor_mut()
1776 .set_prefix(format!("\n{carried}{existing}"));
1777 }
1778 removed
1779}
1780
1781fn apply_key(
1784 document: &mut toml_edit::DocumentMut,
1785 key: &str,
1786 value: Option<toml_edit::Value>,
1787) -> Result<bool, RkError> {
1788 let segments: Vec<&str> = key.split('.').collect();
1789 let Some((last, parents)) = segments.split_last() else {
1792 return Err(invalid(format!("{key} names no key")));
1793 };
1794 let Some(mut value) = value else {
1795 let mut item = document.as_item_mut();
1796 for segment in parents {
1797 if item.get(segment).is_none() {
1798 return Ok(false);
1799 }
1800 item = &mut item[segment];
1801 }
1802 let removed = item
1803 .as_table_mut()
1804 .is_some_and(|table| take_comments(table, last));
1805 return Ok(removed);
1806 };
1807 let mut item = document.as_item_mut();
1808 for segment in parents {
1809 if item.get(segment).is_none() {
1810 let mut table = toml_edit::Table::new();
1811 table.set_implicit(true);
1812 item[segment] = toml_edit::Item::Table(table);
1813 }
1814 item = &mut item[segment];
1815 }
1816 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1817 if same_value(old, &value) {
1818 return Ok(false);
1819 }
1820 *value.decor_mut() = old.decor().clone();
1821 } else if let Some(comment) = migrate::template_comment(&segments) {
1822 value.decor_mut().set_suffix(comment);
1828 }
1829 item[last] = toml_edit::Item::Value(value);
1830 Ok(true)
1831}
1832
1833fn same_value(old: &toml_edit::Value, new: &toml_edit::Value) -> bool {
1837 let parsed = |value: &toml_edit::Value| {
1838 format!("v = {}", value.to_string().trim()).parse::<toml::Table>()
1839 };
1840 matches!((parsed(old), parsed(new)), (Ok(old), Ok(new)) if old == new)
1841}
1842
1843#[derive(Debug, Clone, serde::Serialize)]
1845pub struct Plan {
1846 pub action: &'static str,
1848 pub changes: Vec<String>,
1850 pub content: String,
1852}
1853
1854impl Plan {
1855 pub fn new(
1860 target: &Path,
1861 params: &crate::landing::Params,
1862 existing: Option<&Config>,
1863 record: Option<&crate::landing::manifest::Manifest>,
1864 ) -> Result<Self, RkError> {
1865 let text = existing
1866 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1867 .transpose()?;
1868 Self::compose(text.as_deref(), params, existing, record)
1869 }
1870
1871 pub fn compose(
1878 text: Option<&str>,
1879 params: &crate::landing::Params,
1880 existing: Option<&Config>,
1881 record: Option<&crate::landing::manifest::Manifest>,
1882 ) -> Result<Self, RkError> {
1883 let mut resolved = existing.cloned().unwrap_or_default();
1884 resolved.schema_version = SCHEMA_VERSION;
1885 params.repo().clone_into(&mut resolved.project.repo);
1886 resolved.profile = Profile {
1887 technologies: Some(params.technologies().to_vec()),
1888 forge: Some(params.forge().unwrap_or_default().to_owned()),
1889 release: Release {
1890 mode: Some(params.release_mode()),
1891 driver: params.driver().map(str::to_owned),
1892 style: params.style(),
1893 line_prefix: params.profile().release.line_prefix.clone(),
1894 registry: params.profile().release.registry,
1895 },
1896 };
1897 resolved.git = Git {
1898 trunk: Some(params.trunk().to_owned()),
1899 checkout_mode: Some(params.checkout_mode()),
1900 integration: Some(params.integration()),
1901 merge_form: Some(params.merge_form()),
1902 };
1903 resolved.protection = with_merge_form(
1904 &protection_for(
1905 &resolved.protection,
1906 params.integration(),
1907 existing.is_none(),
1908 ),
1909 params.merge_form(),
1910 );
1911 resolved.capabilities = Capabilities {
1912 nix_packaging: Some(params.nix_packaging()),
1913 reporting_policy: Some(params.reporting_policy()),
1914 scorecard: Some(params.scorecard()),
1915 code_scanning: Some(
1916 params
1917 .code_scanning()
1918 .map_or("off", crate::landing::Provider::as_str)
1919 .to_owned(),
1920 ),
1921 workflow_guards: Some(params.workflow_guards()),
1922 };
1923 if params.release_mode() != crate::profile::ReleaseMode::None || params.reporting_policy() {
1927 resolved.security.contact = Some(params.security_contact().to_owned());
1928 resolved.security.response = Some(params.security_response().to_owned());
1929 }
1930 params
1931 .required_check()
1932 .clone_into(&mut resolved.setup.required_check);
1933 params
1934 .required_workflow()
1935 .clone_into(&mut resolved.setup.required_workflow);
1936 let bare = existing.is_some_and(Config::states_only_a_migration);
1941 let content = if let Some(text) = text.filter(|_| existing.is_some() && !bare) {
1942 rewrite_all(text, parameter_values(&resolved))?
1943 } else {
1944 let rendered =
1945 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?;
1946 with_migration(&rendered, resolved.migration.as_deref())?
1947 };
1948 parse(&content)?;
1949 Ok(Self {
1950 action: if existing.is_some() {
1951 "updated"
1952 } else {
1953 "added"
1954 },
1955 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1956 content,
1957 })
1958 }
1959
1960 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1966 write_checked(target, self.content.as_bytes())
1967 }
1968}
1969
1970#[allow(
1972 clippy::too_many_lines,
1973 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"
1974)]
1975fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1976 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1977 "project.repo",
1978 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1979 )];
1980 if let Some(list) = &config.profile.technologies {
1981 values.push(("profile.technologies", Some(array(list))));
1982 }
1983 if let Some(forge) = config.profile.forge.clone() {
1984 values.push(("profile.forge", Some(forge.into())));
1985 }
1986 if let Some(mode) = config.profile.release.mode {
1987 values.push(("profile.release.mode", Some(mode.as_str().into())));
1988 values.push((
1989 "profile.release.driver",
1990 config
1991 .profile
1992 .release
1993 .driver
1994 .clone()
1995 .map(toml_edit::Value::from),
1996 ));
1997 values.push((
1998 "profile.release.style",
1999 config
2000 .profile
2001 .release
2002 .style
2003 .map(|style| style.as_str().into()),
2004 ));
2005 values.push((
2006 "profile.release.line_prefix",
2007 config
2008 .profile
2009 .release
2010 .line_prefix
2011 .clone()
2012 .map(toml_edit::Value::from),
2013 ));
2014 values.push((
2015 "profile.release.registry",
2016 config
2017 .profile
2018 .release
2019 .registry
2020 .map(|answer| answer.as_str().into()),
2021 ));
2022 }
2023 if let Some(trunk) = config.git.trunk.clone() {
2024 values.push(("git.trunk", Some(trunk.into())));
2025 }
2026 if let Some(mode) = config.git.checkout_mode {
2027 values.push(("git.checkout_mode", Some(mode.as_str().into())));
2028 }
2029 if let Some(mode) = config.git.integration {
2030 values.push(("git.integration", Some(mode.as_str().into())));
2031 let mut bypass = toml_edit::Array::new();
2035 for actor in &config.protection.bypass_actors {
2036 bypass.push(actor.as_str());
2037 }
2038 values.push((
2039 "protection.bypass_actors",
2040 Some(toml_edit::Value::Array(bypass)),
2041 ));
2042 let mut rules = toml_edit::Array::new();
2043 for rule in &config.protection.owned_trunk_rules {
2044 rules.push(rule.as_str());
2045 }
2046 values.push((
2047 "protection.owned_trunk_rules",
2048 Some(toml_edit::Value::Array(rules)),
2049 ));
2050 values.push((
2051 "protection.gitlab.push_access_level",
2052 Some(config.protection.gitlab.push_access_level.into()),
2053 ));
2054 }
2055 if let Some(form) = config.git.merge_form {
2056 values.push(("git.merge_form", Some(form.as_str().into())));
2057 let mut methods = toml_edit::Array::new();
2062 for method in &config.protection.allowed_merge_methods {
2063 methods.push(method.as_str());
2064 }
2065 values.push((
2066 "protection.allowed_merge_methods",
2067 Some(toml_edit::Value::Array(methods)),
2068 ));
2069 values.push((
2070 "protection.gitlab.squash_option",
2071 Some(config.protection.gitlab.squash_option.clone().into()),
2072 ));
2073 }
2074 if let Some(value) = config.capabilities.nix_packaging {
2075 values.push(("capabilities.nix_packaging", Some(value.into())));
2076 }
2077 if let Some(value) = config.capabilities.reporting_policy {
2078 values.push(("capabilities.reporting_policy", Some(value.into())));
2079 }
2080 if let Some(value) = config.capabilities.scorecard {
2081 values.push(("capabilities.scorecard", Some(value.into())));
2082 }
2083 if let Some(value) = config.capabilities.code_scanning.clone() {
2084 values.push(("capabilities.code_scanning", Some(value.into())));
2085 }
2086 if let Some(value) = config.capabilities.workflow_guards {
2087 values.push(("capabilities.workflow_guards", Some(value.into())));
2088 }
2089 if let Some(value) = config.security.contact.clone() {
2092 values.push(("security.contact", Some(value.into())));
2093 }
2094 if let Some(value) = config.security.response.clone() {
2095 values.push(("security.response", Some(value.into())));
2096 }
2097 values.push((
2102 "setup.required_check",
2103 Some(config.setup.required_check.clone().into()),
2104 ));
2105 values.push((
2106 "setup.required_workflow",
2107 Some(config.setup.required_workflow.clone().into()),
2108 ));
2109 values
2110}
2111
2112#[must_use]
2114pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
2115 let recorded = {
2116 let params = crate::landing::Params::from_record(record);
2117 Plan::compose(None, ¶ms, None, None)
2118 .ok()
2119 .and_then(|plan| parse(&plan.content).ok())
2120 };
2121 let Some(recorded) = recorded else {
2122 return Vec::new();
2123 };
2124 let render = |value: &Option<toml_edit::Value>| {
2125 value.as_ref().map_or_else(
2126 || "<absent>".to_owned(),
2127 |value| value.to_string().trim().to_owned(),
2128 )
2129 };
2130 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
2131 .iter()
2132 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2133 .map(|(key, value)| (*key, render(value)))
2134 .collect();
2135 let mut keys: Vec<String> = parameter_values(config)
2136 .into_iter()
2137 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2138 .map(|(key, value)| (key, render(&value)))
2139 .filter(|(key, value)| {
2140 baseline
2141 .iter()
2142 .any(|(other, old)| key == other && value != old)
2143 })
2144 .map(|(key, _)| key.to_owned())
2145 .collect();
2146 if let Some(answer) = &config.dev_environment
2150 && answer.clone().answered() != record.parameters.dev_environment
2151 {
2152 keys.push("dev_environment".to_owned());
2153 }
2154 keys
2155}
2156
2157#[must_use]
2165pub(crate) fn local_protection() -> Protection {
2166 const MAINTAINER: i64 = 40;
2168 Protection {
2169 bypass_actors: vec![LOCAL_GITHUB_BYPASS.into()],
2170 gitlab: Gitlab {
2171 push_access_level: MAINTAINER,
2172 ..Gitlab::default()
2173 },
2174 ..Protection::default()
2175 }
2176}
2177
2178#[must_use]
2185pub const fn merge_values(form: MergeForm) -> (&'static str, &'static str) {
2186 match form {
2187 MergeForm::Squash => ("squash", "always"),
2188 MergeForm::Rebase => ("rebase", "never"),
2189 }
2190}
2191
2192#[must_use]
2197pub fn with_merge_form(policy: &Protection, form: MergeForm) -> Protection {
2198 let (method, squash_option) = merge_values(form);
2199 let mut next = policy.clone();
2200 next.allowed_merge_methods = vec![method.to_owned()];
2201 squash_option.clone_into(&mut next.gitlab.squash_option);
2202 next
2203}
2204
2205#[must_use]
2208pub(crate) fn legacy_local_protection(policy: &Protection) -> bool {
2209 policy.bypass_actors.is_empty()
2210 && policy.owned_trunk_rules == ["deletion", "non_fast_forward"]
2211 && policy.gitlab.push_access_level >= 40
2212}
2213
2214#[must_use]
2236fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
2237 let forge = Protection::default();
2238 let local = local_protection();
2239 let tuple = |policy: &Protection| {
2240 (
2241 policy.bypass_actors.clone(),
2242 policy.owned_trunk_rules.clone(),
2243 policy.gitlab.push_access_level,
2244 )
2245 };
2246 let taken = fresh
2247 || tuple(held) == tuple(&forge)
2248 || tuple(held) == tuple(&local)
2249 || legacy_local_protection(held);
2250 if !taken {
2251 return held.clone();
2252 }
2253 let mut next = held.clone();
2254 let source = match integration {
2255 Integration::Forge => forge,
2256 Integration::Local => local,
2257 };
2258 next.bypass_actors = source.bypass_actors;
2259 next.owned_trunk_rules = source.owned_trunk_rules;
2260 next.gitlab.push_access_level = source.gitlab.push_access_level;
2261 next
2262}
2263
2264pub fn trunk_of(target: &Path) -> Result<String, RkError> {
2269 Ok(load(target)?
2270 .and_then(|config| config.git.trunk)
2271 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
2272}
2273
2274pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
2279 Ok(load(target)?
2280 .and_then(|config| config.profile.release.line_prefix)
2281 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
2282}
2283
2284#[cfg(test)]
2285mod tests {
2286 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, set_migration, trunk_of, write};
2287 use crate::landing::{CheckoutMode, Integration, MergeForm, Style};
2288 use crate::profile::ReleaseMode;
2289
2290 fn resolved_defaults() -> Config {
2293 Config {
2294 project: super::Project {
2295 repo: "acme/widget".into(),
2296 },
2297 profile: super::Profile {
2298 technologies: Some(vec!["rust".into()]),
2299 forge: Some("github".into()),
2300 release: super::Release {
2301 mode: Some(ReleaseMode::Automatic),
2302 driver: Some("rust".into()),
2303 style: Some(Style::Trunk),
2304 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
2305 registry: None,
2306 },
2307 },
2308 git: super::Git {
2309 trunk: Some(super::TRUNK_DEFAULT.into()),
2310 checkout_mode: Some(CheckoutMode::LinkedWorktree),
2311 integration: Some(Integration::Local),
2312 merge_form: Some(MergeForm::Squash),
2313 },
2314 capabilities: super::Capabilities {
2315 nix_packaging: Some(false),
2316 reporting_policy: Some(true),
2317 scorecard: Some(false),
2318 code_scanning: Some("off".to_owned()),
2319 workflow_guards: Some(false),
2320 },
2321 security: super::Security {
2324 contact: Some(String::new()),
2325 response: Some(super::RESPONSE_DEFAULT.into()),
2326 ..super::Security::default()
2327 },
2328 protection: super::Protection {
2331 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
2332 safety_ruleset: Some(format!("{}-safety", super::TRUNK_DEFAULT)),
2333 ..super::local_protection()
2334 },
2335 ..Config::default()
2336 }
2337 }
2338
2339 #[test]
2342 fn a_symlinked_configuration_path_refuses_every_writer() {
2343 let outside = tempfile::tempdir().expect("an outside directory exists");
2344 let text = "schema_version = 2\n";
2345 std::fs::write(outside.path().join("config.toml"), text).expect("writes");
2346 let linked_dir = tempfile::tempdir().expect("a tempdir");
2347 std::os::unix::fs::symlink(outside.path(), linked_dir.path().join(".release-kit"))
2348 .expect("links the directory");
2349 let linked_file = tempfile::tempdir().expect("a tempdir");
2350 std::fs::create_dir_all(linked_file.path().join(".release-kit")).expect("mkdir");
2351 std::os::unix::fs::symlink(
2352 outside.path().join("config.toml"),
2353 linked_file.path().join(CONFIG_PATH),
2354 )
2355 .expect("links the file");
2356 for dir in [&linked_dir, &linked_file] {
2357 let target = dir.path();
2358 let refusals = [
2359 write(target, &resolved_defaults()).expect_err("write refuses"),
2360 rewrite_key(target, "git.trunk", "main".into()).expect_err("rewrite refuses"),
2361 set_migration(target, Some("20261009T140205Z")).expect_err("the key refuses"),
2362 ];
2363 for error in refusals {
2364 assert_eq!(error.exit_code(), 73, "{error}");
2365 assert!(error.to_string().contains("symlink"), "{error}");
2366 }
2367 }
2368 assert_eq!(
2369 std::fs::read_to_string(outside.path().join("config.toml")).expect("reads"),
2370 text
2371 );
2372 }
2373
2374 #[test]
2375 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
2376 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
2377 let explicit = parse(
2378 "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",
2379 )
2380 .expect("explicit defaults parse");
2381 assert_eq!(omitted.git, super::Git::default());
2382 assert_eq!(omitted.capabilities, super::Capabilities::default());
2383 assert_eq!(
2384 explicit.git.checkout_mode,
2385 Some(CheckoutMode::LinkedWorktree)
2386 );
2387 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
2388 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
2389 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
2390 assert_eq!(explicit.capabilities.scorecard, Some(false));
2391 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
2392 assert_ne!(omitted, explicit);
2393 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
2395 .expect("the older spelling reads");
2396 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
2397 }
2398
2399 #[test]
2403 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
2404 let dir = tempfile::tempdir().expect("a tempdir");
2405 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
2406 std::fs::write(
2407 dir.path().join(CONFIG_PATH),
2408 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
2409 )
2410 .expect("the config writes");
2411 let held = load(dir.path())
2412 .expect("the config reads")
2413 .expect("it is present");
2414 assert_eq!(held.setup.bot.app_id, "123");
2415 assert_eq!(
2416 held.setup.bot.installation_id,
2417 Some(0),
2418 "the key parses; nothing reads it"
2419 );
2420 }
2421
2422 #[test]
2424 fn a_schema_1_config_migrates_into_its_domains() {
2425 let held = parse(
2426 "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",
2427 )
2428 .expect("a schema 1 file reads");
2429 assert_eq!(held.schema_version, 2);
2430 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
2431 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
2432 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
2433 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
2434 assert_eq!(held.profile.release.style, Some(Style::Lines));
2435 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
2436 assert_eq!(held.git.trunk.as_deref(), Some("main"));
2437 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
2438 assert_eq!(held.capabilities.nix_packaging, Some(false));
2439 assert_eq!(held.capabilities.reporting_policy, Some(true));
2440 }
2441
2442 #[test]
2444 fn every_invalid_release_state_names_its_key() {
2445 for (text, key) in [
2446 (
2447 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
2448 "profile.release.style",
2449 ),
2450 (
2451 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
2452 "profile.release.driver",
2453 ),
2454 (
2455 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
2456 "profile.release.line_prefix",
2457 ),
2458 (
2459 "[profile]\ntechnologies = ['rust', 'rust']\n",
2460 "profile.technologies",
2461 ),
2462 (
2463 "[profile]\ntechnologies = ['Rust!']\n",
2464 "profile.technologies",
2465 ),
2466 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
2467 (
2468 "[profile.release]\nmode = 'manual'\n",
2469 "profile.release.mode",
2470 ),
2471 ] {
2472 let error = parse(&format!("schema_version = 2\n{text}"))
2473 .expect_err("an invalid release state refuses")
2474 .to_string();
2475 assert!(error.contains(key), "{key}: {error}");
2476 }
2477 }
2478
2479 #[test]
2483 fn a_newly_written_key_takes_the_templates_comment() {
2484 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
2485 let next = super::rewrite_text(
2486 text,
2487 "git.integration",
2488 Some(toml_edit::Value::from("forge")),
2489 )
2490 .expect("the key writes");
2491 assert!(
2492 next.contains("integration = \"forge\" # P: local or forge"),
2493 "{next}"
2494 );
2495 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
2497 .expect("the key writes");
2498 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
2499 }
2500
2501 #[test]
2502 fn the_landed_config_template_round_trips() {
2503 let dir = tempfile::tempdir().expect("a target exists");
2504 let mut config = Config::default();
2505 config.project.repo = "acme/nested/widget".into();
2506 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
2507 config.profile.forge = Some("gitlab".into());
2508 config.profile.release = super::Release {
2509 mode: Some(ReleaseMode::Automatic),
2510 driver: Some("bash".into()),
2511 style: Some(Style::Lines),
2512 line_prefix: Some("stable/".into()),
2513 registry: None,
2514 };
2515 config.git.trunk = Some("main".into());
2516 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
2517 config.git.integration = Some(Integration::Forge);
2518 config.git.merge_form = Some(MergeForm::Rebase);
2519 config.protection = super::with_merge_form(&config.protection, MergeForm::Rebase);
2520 config.capabilities.nix_packaging = Some(true);
2521 config.capabilities.reporting_policy = Some(false);
2522 config.capabilities.scorecard = Some(true);
2523 config.capabilities.code_scanning = Some("semgrep".to_owned());
2524 config.capabilities.workflow_guards = Some(true);
2525 config.security.advisories =
2529 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
2530 config.security.contact = Some("security team, room 3 \"the vault\"".into());
2531 config.security.response = Some("14 business days".into());
2532 config.setup.required_check = "build / test".into();
2533 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
2534 config.setup.release_lines = true;
2535 config.setup.excluded_steps = [
2536 (
2537 "package-check".to_owned(),
2538 "nothing is published".to_owned(),
2539 ),
2540 (
2541 "protect-trunk".to_owned(),
2542 "this project merges \"locally\"".to_owned(),
2543 ),
2544 ]
2545 .into_iter()
2546 .collect();
2547 config.setup.bot.app_id = "123".into();
2548 config.protection.trunk_ruleset = Some("primary".into());
2549 config.protection.safety_ruleset = Some("guardrail".into());
2550 config.protection.tag_ruleset = "versions".into();
2551 config.protection.lines_ruleset = "maintenance".into();
2552 config.protection.title_check = "intent".into();
2553 config.protection.tag_pattern = "refs/tags/*".into();
2554 config
2555 .protection
2556 .owned_trunk_rules
2557 .push("required_signatures".into());
2558 config.protection.required_approving_review_count = 2;
2559 config.protection.dismiss_stale_reviews_on_push = true;
2560 config.protection.require_code_owner_review = true;
2561 config.protection.require_last_push_approval = true;
2562 config.protection.gitlab.squash_commit_template =
2563 "%{title}\n\nContext: %{description}".into();
2564 config.protection.gitlab.merge_access_level = 40;
2565 let mut release_less = resolved_defaults();
2568 release_less.project.repo = String::new();
2569 release_less.profile.technologies = Some(Vec::new());
2570 release_less.profile.forge = Some(String::new());
2571 release_less.profile.release = super::Release {
2572 mode: Some(ReleaseMode::None),
2573 driver: None,
2574 style: None,
2575 line_prefix: None,
2576 registry: None,
2577 };
2578 release_less.capabilities.reporting_policy = Some(false);
2579 release_less.security.contact = None;
2582 release_less.security.response = None;
2583 for expected in [resolved_defaults(), config, release_less] {
2584 write(dir.path(), &expected).expect("the template renders");
2585 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
2586 let text =
2587 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2588 assert!(text.contains("# P: every technology"));
2589 assert!(text.contains("# F: invariant"));
2590 }
2591 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2592 assert!(
2593 !text.contains("style ="),
2594 "a none release writes no style: {text}"
2595 );
2596 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
2597 }
2598
2599 #[test]
2600 fn a_config_with_an_unknown_key_refuses_by_name() {
2601 for (table, typo, nearest) in [
2602 ("", "schemax_version", "schema_version"),
2603 ("git", "trunkx", "trunk"),
2604 ("profile.release", "stile", "style"),
2605 ("capabilities", "nix_packagingg", "nix_packaging"),
2606 ("security", "contactx", "contact"),
2607 ("setup", "required_checkx", "required_check"),
2608 ("setup.bot", "app_i", "app_id"),
2609 ("protection", "trunk_rulesett", "trunk_ruleset"),
2610 (
2611 "protection.github",
2612 "squash_body_sourcex",
2613 "squash_body_source",
2614 ),
2615 ("protection.gitlab", "squash_optionx", "squash_option"),
2616 ] {
2617 let header = if table.is_empty() {
2618 String::new()
2619 } else {
2620 format!("[{table}]\n")
2621 };
2622 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
2623 let error = parse(&text).expect_err("unknown keys refuse").to_string();
2624 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
2625 assert!(error.contains(expected), "{error}");
2626 }
2627 }
2628 }
2629
2630 #[test]
2634 fn an_exclusion_names_a_real_step_and_states_why() {
2635 for (text, expected) in [
2636 (
2637 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
2638 vec!["protect-trunkk", "nearest known step: protect-trunk"],
2639 ),
2640 (
2641 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
2642 vec!["protect-trunk", "no reason"],
2643 ),
2644 ] {
2645 let error = parse(&format!("schema_version = 2\n{text}"))
2646 .expect_err("the exclusion refuses")
2647 .to_string();
2648 for want in expected {
2649 assert!(error.contains(want), "{error}");
2650 }
2651 }
2652 let held = parse(
2653 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
2654 )
2655 .expect("a named step with a reason parses");
2656 assert_eq!(
2657 held.setup
2658 .excluded_steps
2659 .get("protect-trunk")
2660 .map(String::as_str),
2661 Some("we merge locally")
2662 );
2663 }
2664
2665 #[test]
2669 fn an_exclusion_does_not_lift_a_floor() {
2670 let error = parse(
2671 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
2672 )
2673 .expect_err("the floor binds an excluded step's keys too")
2674 .to_string();
2675 assert!(
2676 error.contains("protection.allowed_merge_methods"),
2677 "{error}"
2678 );
2679 }
2680
2681 #[test]
2682 fn a_config_at_an_unknown_schema_refuses() {
2683 for text in ["schema_version = 999", "schema_version = '2'", ""] {
2684 let error = parse(text)
2685 .expect_err("a schema must be declared and known")
2686 .to_string();
2687 assert!(
2688 error.contains(CONFIG_PATH) && error.contains("schema_version"),
2689 "{error}"
2690 );
2691 }
2692 }
2693
2694 #[test]
2695 fn an_unparsable_config_refuses_naming_the_position() {
2696 let error = parse("schema_version = 2\n[project\n")
2697 .expect_err("bad TOML refuses")
2698 .to_string();
2699 for expected in [CONFIG_PATH, "line 2", "column"] {
2700 assert!(error.contains(expected), "{error}");
2701 }
2702 }
2703
2704 #[test]
2705 fn an_absent_config_reads_as_none() {
2706 let dir = tempfile::tempdir().expect("a target exists");
2707 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2708 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2709 }
2710
2711 #[test]
2712 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2713 let dir = tempfile::tempdir().expect("a target exists");
2714 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2715 std::fs::write(
2716 dir.path().join(CONFIG_PATH),
2717 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2718 )
2719 .expect("a config exists");
2720 let error =
2721 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2722 assert_eq!(error.exit_code(), 73);
2723 assert!(
2724 error
2725 .to_string()
2726 .contains("protection.strict_required_status_checks")
2727 );
2728 }
2729
2730 #[test]
2733 fn an_equal_value_keeps_its_spelling() {
2734 let original = "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'tr\\u0075nk' # ours\n";
2735 let original = original.replace("'tr\\u0075nk'", "\"tr\\u0075nk\"");
2736 let text = super::rewrite_text(&original, "profile.release.style", Some("trunk".into()))
2737 .expect("the style rewrites");
2738 assert_eq!(text, original, "an equal value is rewritten");
2739 let quoted =
2740 "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk' # ours\n";
2741 let text = super::rewrite_text(quoted, "profile.release.style", Some("trunk".into()))
2742 .expect("the style rewrites");
2743 assert_eq!(
2744 text, quoted,
2745 "a literal string equal to the value is rewritten"
2746 );
2747 }
2748
2749 #[test]
2750 fn rewrite_key_preserves_comments() {
2751 let dir = tempfile::tempdir().expect("a target exists");
2752 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2753 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";
2754 let path = dir.path().join(CONFIG_PATH);
2755 std::fs::write(&path, original).expect("a config exists");
2756 rewrite_key(dir.path(), "profile.release.style", "lines".into())
2757 .expect("the style writes back");
2758 let text = std::fs::read_to_string(&path).expect("the text reads");
2759 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2760 assert_eq!(
2761 load(dir.path())
2762 .expect("the config reads")
2763 .expect("present")
2764 .profile
2765 .release
2766 .style,
2767 Some(Style::Lines)
2768 );
2769 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2770 .expect("an omitted table can be added");
2771 assert_eq!(
2772 load(dir.path())
2773 .expect("reads")
2774 .expect("present")
2775 .project
2776 .repo,
2777 "acme/widget"
2778 );
2779 rewrite_key(
2780 dir.path(),
2781 "security.contact",
2782 "security@acme.example".into(),
2783 )
2784 .expect("the contact is a landing parameter");
2785 rewrite_key(dir.path(), "security.response", "14 days".into())
2786 .expect("the response is a landing parameter");
2787 let held = load(dir.path()).expect("reads").expect("present");
2788 assert_eq!(
2789 held.security.contact.as_deref(),
2790 Some("security@acme.example")
2791 );
2792 assert_eq!(held.security.response.as_deref(), Some("14 days"));
2793 let text = std::fs::read_to_string(&path).expect("the text reads");
2794 assert!(text.contains("# keep me"), "the comment survives: {text}");
2795 let before = std::fs::read(&path).expect("the bytes read");
2796 for (key, value) in [
2797 ("security.advisories", "acme/private"),
2798 ("security.response", "90d"),
2799 ("profile.release.style", "unknown"),
2800 ] {
2801 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2802 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2803 }
2804 }
2805
2806 #[test]
2809 fn the_security_answers_are_held_to_their_grammar() {
2810 for value in ["team@acme.example", " https://acme.example/report ", ""] {
2811 super::canonical_contact(value).expect("a control-free line is a contact");
2812 }
2813 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2814 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2815 assert!(refusal.contains("security.contact"), "{refusal}");
2816 }
2817 assert_eq!(
2818 super::canonical_contact(" team@acme.example "),
2819 Ok("team@acme.example".to_owned()),
2820 "surrounding whitespace is trimmed"
2821 );
2822 for value in [
2823 "best-effort",
2824 "1 day",
2825 "2 days",
2826 "14 days",
2827 "1 business day",
2828 "14 business days",
2829 ] {
2830 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2831 }
2832 assert_eq!(
2833 super::canonical_response(""),
2834 Ok(super::RESPONSE_DEFAULT.to_owned()),
2835 "an empty answer reads as the compiled default"
2836 );
2837 for value in [
2838 "0 days",
2839 "1 days",
2840 "2 day",
2841 "+2 days",
2842 "02 days",
2843 "4294967296 days",
2844 "90d",
2845 "two days",
2846 "we answer quickly",
2847 "2 weeks",
2848 ] {
2849 let refusal =
2850 super::canonical_response(value).expect_err("an unstateable window refuses");
2851 assert!(refusal.contains("security.response"), "{value}: {refusal}");
2852 assert!(refusal.contains("business days"), "{value}: {refusal}");
2853 }
2854 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2855 .expect_err("the reader refuses it too")
2856 .to_string();
2857 assert!(refusal.contains("security.response"), "{refusal}");
2858 }
2859
2860 #[test]
2864 fn a_migration_key_that_is_no_identifier_refuses() {
2865 for id in ["20261009T140205Z", "20261009T140205Z-2"] {
2866 let config = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2867 .expect("an identifier parses");
2868 assert_eq!(config.migration.as_deref(), Some(id));
2869 }
2870 for id in [
2871 "../../elsewhere",
2872 "20261009T140205Z-1",
2873 "",
2874 "20261009T140205\u{e9}",
2875 "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
2876 ] {
2877 let refusal = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2878 .expect_err("a value that is no identifier refuses")
2879 .to_string();
2880 assert!(
2881 refusal.contains("no migration identifier"),
2882 "{id}: {refusal}"
2883 );
2884 }
2885 }
2886
2887 #[test]
2892 fn a_pruned_header_leaves_no_comment_behind() {
2893 let cases = [
2894 (
2895 "a middle table, comment above",
2896 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2897 ),
2898 (
2899 "a middle table, comment inline",
2900 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2901 ),
2902 (
2903 "the last table, comment above",
2904 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
2905 ),
2906 (
2907 "the last table, comment inline",
2908 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
2909 ),
2910 ];
2911 for (case, text) in cases {
2912 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2913 assert!(!next.contains("repo ="), "{case}: {next}");
2914 assert!(
2915 !next.contains("[project]"),
2916 "{case}: a table every one of whose keys dropped goes with them: {next}"
2917 );
2918 assert!(
2919 next.contains("# the operator's note"),
2920 "{case}: the comment the header carried survives: {next}"
2921 );
2922 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2923 }
2924 }
2925
2926 #[test]
2932 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2933 let text = concat!(
2934 "schema_version = 2\n\n[project]\n",
2935 "# the operator's note\n",
2936 "repo = \"acme/widget\" # P: project path on the forge\n\n",
2937 "[git]\ntrunk = \"main\"\n"
2938 );
2939 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2940 assert!(!next.contains("repo ="), "{next}");
2941 assert!(!next.contains("[project]"), "{next}");
2942 assert!(
2943 next.contains("# the operator's note"),
2944 "authored text survives: {next}"
2945 );
2946 assert!(
2947 !next.contains("# P:"),
2948 "the template's comment describes a key that is gone: {next}"
2949 );
2950 parse(&next).expect("the result parses");
2951
2952 let text = concat!(
2954 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2955 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2956 "# why this project names its own prefix\n",
2957 "line_prefix = \"stable/\"\n"
2958 );
2959 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2960 .expect("the key removes");
2961 assert!(!next.contains("line_prefix ="), "{next}");
2962 assert!(next.contains("[profile.release]"), "{next}");
2963 assert!(
2964 next.contains("# why this project names its own prefix"),
2965 "authored text survives: {next}"
2966 );
2967 }
2968}