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}
171
172#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
174#[serde(deny_unknown_fields, default)]
175pub struct Git {
176 pub trunk: Option<String>,
180 pub checkout_mode: Option<CheckoutMode>,
182 pub integration: Option<Integration>,
185 pub merge_form: Option<MergeForm>,
187}
188
189#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
191#[serde(deny_unknown_fields, default)]
192pub struct Capabilities {
193 pub nix_packaging: Option<bool>,
195 pub reporting_policy: Option<bool>,
197 pub scorecard: Option<bool>,
199 pub code_scanning: Option<String>,
203 pub workflow_guards: Option<bool>,
206}
207
208pub const TRUNK_DEFAULT: &str = "master";
210
211pub const LINE_PREFIX_DEFAULT: &str = "release/";
213
214#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
216#[serde(deny_unknown_fields, default)]
217pub struct Security {
218 pub advisories: String,
220 pub contact: Option<String>,
225 pub response: Option<String>,
228}
229
230pub const RESPONSE_DEFAULT: &str = "best-effort";
233
234pub const LOCAL_GITHUB_BYPASS: &str = "repository-admin";
240
241pub fn canonical_contact(raw: &str) -> Result<String, String> {
251 let trimmed = raw.trim();
252 if trimmed.chars().any(char::is_control) {
253 return Err(format!(
254 "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:?}"
255 ));
256 }
257 Ok(trimmed.to_owned())
258}
259
260pub fn canonical_response(raw: &str) -> Result<String, String> {
272 let trimmed = raw.trim();
273 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
274 return Ok(RESPONSE_DEFAULT.to_owned());
275 }
276 let refusal = || {
277 format!(
278 "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:?}"
279 )
280 };
281 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
282 let plural = match unit {
283 "day" | "business day" => false,
284 "days" | "business days" => true,
285 _ => return Err(refusal()),
286 };
287 let number: u32 = count.parse().map_err(|_| refusal())?;
288 if count != number.to_string() || (number > 1) != plural {
291 return Err(refusal());
292 }
293 Ok(trimmed.to_owned())
294}
295
296#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
298#[serde(deny_unknown_fields, default)]
299pub struct Setup {
300 pub required_check: String,
303 pub required_workflow: String,
307 pub retired_branches: Vec<String>,
309 pub release_lines: bool,
311 pub excluded_steps: BTreeMap<String, String>,
316 pub bot: Bot,
318}
319
320impl Default for Setup {
321 fn default() -> Self {
322 Self {
323 required_check: String::new(),
324 required_workflow: String::new(),
325 retired_branches: vec!["main".into(), "develop".into()],
326 release_lines: false,
327 excluded_steps: BTreeMap::new(),
328 bot: Bot::default(),
329 }
330 }
331}
332
333#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
340#[serde(deny_unknown_fields, default)]
341pub struct Bot {
342 pub app_id: String,
344 #[serde(default, skip_serializing)]
349 pub installation_id: Option<i64>,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
354#[serde(deny_unknown_fields, default)]
355#[allow(
356 clippy::struct_excessive_bools,
357 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
358)]
359pub struct Protection {
360 pub trunk_ruleset: Option<String>,
364 pub safety_ruleset: Option<String>,
369 pub tag_ruleset: String,
371 pub lines_ruleset: String,
373 pub title_check: String,
375 pub tag_pattern: String,
377 pub bypass_actors: Vec<String>,
380 pub allowed_merge_methods: Vec<String>,
383 pub strict_required_status_checks: bool,
385 pub owned_trunk_rules: Vec<String>,
389 pub required_approving_review_count: i64,
391 pub dismiss_stale_reviews_on_push: bool,
393 pub require_code_owner_review: bool,
395 pub require_last_push_approval: bool,
397 pub github: Github,
399 pub gitlab: Gitlab,
401}
402
403impl Default for Protection {
404 fn default() -> Self {
405 Self {
406 trunk_ruleset: None,
407 safety_ruleset: None,
408 tag_ruleset: "release-tags".into(),
409 lines_ruleset: "release-lines".into(),
410 title_check: "pr-title".into(),
411 tag_pattern: "refs/tags/v*".into(),
412 bypass_actors: Vec::new(),
413 allowed_merge_methods: vec!["squash".into()],
414 strict_required_status_checks: true,
415 owned_trunk_rules: vec![
416 "deletion".into(),
417 "non_fast_forward".into(),
418 "pull_request".into(),
419 "required_status_checks".into(),
420 ],
421 required_approving_review_count: 0,
422 dismiss_stale_reviews_on_push: false,
423 require_code_owner_review: false,
424 require_last_push_approval: false,
425 github: Github::default(),
426 gitlab: Gitlab::default(),
427 }
428 }
429}
430
431impl Protection {
432 #[must_use]
435 pub fn trunk_ruleset(&self, trunk: &str) -> String {
436 self.trunk_ruleset
437 .clone()
438 .unwrap_or_else(|| format!("{trunk}-protection"))
439 }
440
441 #[must_use]
448 pub fn safety_ruleset(&self, trunk: &str) -> String {
449 self.safety_ruleset
450 .clone()
451 .unwrap_or_else(|| format!("{trunk}-safety"))
452 }
453}
454
455pub const SAFETY_RULES: [&str; 3] = ["deletion", "non_fast_forward", LINEAR_HISTORY_RULE];
465
466pub const COMMIT_GATE_CHECK: &str = "pr-commits";
470
471pub const LINEAR_HISTORY_RULE: &str = "required_linear_history";
475
476pub const REQUEST_RULES: [&str; 2] = ["pull_request", "required_status_checks"];
479
480#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
482#[serde(deny_unknown_fields, default)]
483pub struct Github {
484 pub squash_title_source: String,
487 pub squash_body_source: String,
489}
490
491impl Default for Github {
492 fn default() -> Self {
493 Self {
494 squash_title_source: "PR_TITLE".into(),
495 squash_body_source: "PR_BODY".into(),
496 }
497 }
498}
499
500#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
502#[serde(deny_unknown_fields, default)]
503pub struct Gitlab {
504 pub merge_method: String,
506 pub squash_option: String,
510 pub squash_commit_template: String,
512 pub push_access_level: i64,
517 pub merge_access_level: i64,
519}
520
521impl Default for Gitlab {
522 fn default() -> Self {
523 Self {
524 merge_method: "ff".into(),
525 squash_option: "always".into(),
526 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
527 .trim_end_matches('\n')
528 .to_owned(),
529 push_access_level: 0,
530 merge_access_level: 40,
531 }
532 }
533}
534
535pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
540 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
541 Ok(text) => text,
542 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
543 Err(error) => return Err(error.into()),
544 };
545 parse(&text).map(Some)
546}
547
548fn current_text(text: &str) -> Result<String, RkError> {
551 let raw: toml::Value =
552 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
553 match raw.get("schema_version").and_then(toml::Value::as_integer) {
554 Some(1) => migrate::to_schema_2(text),
555 _ => Ok(text.to_owned()),
556 }
557}
558
559fn parse(text: &str) -> Result<Config, RkError> {
560 let text = current_text(text)?;
561 let raw: toml::Value =
562 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
563 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
564 return Err(invalid(format!(
565 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
566 )));
567 }
568 if let Some(mode) = raw
572 .get("profile")
573 .and_then(|profile| profile.get("release"))
574 .and_then(|release| release.get("mode"))
575 {
576 let named = mode.as_str().ok_or_else(|| {
577 invalid("profile.release.mode must be a string: automatic, external, or none")
578 })?;
579 crate::profile::ReleaseMode::parse(named)
580 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
581 }
582 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
583 let mut message = error.to_string();
584 if let Some(rest) = error.message().strip_prefix("unknown field `") {
585 let names: Vec<_> = rest.split('`').collect();
586 if let Some(unknown) = names.first()
587 && let Some(nearest) = names
588 .iter()
589 .skip(2)
590 .step_by(2)
591 .min_by_key(|name| distance(unknown, name))
592 {
593 let _ = write!(message, "; nearest known key: {nearest}");
594 }
595 }
596 invalid(message)
597 })?;
598 if let Some(technologies) = &config.profile.technologies {
599 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
600 }
601 if let Some(forge) = &config.profile.forge
602 && !forge.is_empty()
603 {
604 crate::profile::canonical_category(forge)
605 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
606 }
607 if let Some(driver) = &config.profile.release.driver {
608 crate::profile::canonical_category(driver)
609 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
610 }
611 if matches!(
612 config.profile.release.mode,
613 Some(ReleaseMode::External | ReleaseMode::None)
614 ) {
615 let mode = config
616 .profile
617 .release
618 .mode
619 .map_or("none", ReleaseMode::as_str);
620 for (key, present) in [
621 (
622 "profile.release.driver",
623 config.profile.release.driver.is_some(),
624 ),
625 (
626 "profile.release.style",
627 config.profile.release.style.is_some(),
628 ),
629 (
630 "profile.release.line_prefix",
631 config.profile.release.line_prefix.is_some(),
632 ),
633 ] {
634 if present {
635 return Err(invalid(format!(
636 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
637 )));
638 }
639 }
640 }
641 if let Some(contact) = &config.security.contact {
642 canonical_contact(contact).map_err(invalid)?;
643 }
644 if let Some(response) = &config.security.response {
645 canonical_response(response).map_err(invalid)?;
646 }
647 exclusions(&config.setup.excluded_steps)?;
648 if let Some(acquisition) = &config.acquisition
649 && !is_release_version(&acquisition.minimum)
650 {
651 return Err(invalid(format!(
652 "acquisition.minimum is {}, which is no release version; write one such as {}",
653 acquisition.minimum,
654 env!("CARGO_PKG_VERSION")
655 )));
656 }
657 if let Some(answer) = &config.dev_environment {
658 answer.check().map_err(invalid)?;
659 }
660 migration_key(&config)?;
661 floors::check(&config)?;
662 Ok(config)
663}
664
665fn migration_key(config: &Config) -> Result<(), RkError> {
668 match &config.migration {
669 Some(id) if !crate::migrate::is_id(id) => Err(invalid(format!(
670 "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)"
671 ))),
672 _ => Ok(()),
673 }
674}
675
676fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
683 for (name, reason) in excluded {
684 if crate::setup::steps::spec(name).is_none() {
685 let nearest = crate::setup::steps::STEPS
686 .iter()
687 .min_by_key(|step| distance(name, step.name))
688 .map_or("", |step| step.name);
689 return Err(invalid(format!(
690 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
691 )));
692 }
693 if reason.trim().is_empty() {
694 return Err(invalid(format!(
695 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
696 )));
697 }
698 }
699 Ok(())
700}
701
702pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
703 RkError::refusal(
704 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
705 .action(format!("edit {CONFIG_PATH} and retry"))
706 .target_state("nothing was written"),
707 )
708}
709
710fn distance(left: &str, right: &str) -> usize {
711 let mut row: Vec<_> = (0..=right.chars().count()).collect();
712 for (i, a) in left.chars().enumerate() {
713 let mut previous = row[0];
714 row[0] = i + 1;
715 for (j, b) in right.chars().enumerate() {
716 let old = row[j + 1];
717 row[j + 1] = (previous + usize::from(a != b))
718 .min(row[j] + 1)
719 .min(old + 1);
720 previous = old;
721 }
722 }
723 row.last().copied().unwrap_or(0)
724}
725
726pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
732 let bytes = render(config)?;
733 parse(&String::from_utf8_lossy(&bytes))?;
734 write_checked(target, &bytes)
735}
736
737fn write_checked(target: &Path, bytes: &[u8]) -> Result<(), RkError> {
740 check_writable(target)?;
741 crate::atomic::write(&target.join(CONFIG_PATH), bytes)?;
742 Ok(())
743}
744
745fn array(values: &[String]) -> toml_edit::Value {
746 toml_edit::Value::Array(values.iter().collect())
747}
748
749fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
754 let mut table = toml_edit::InlineTable::new();
755 for (key, value) in values {
756 table.insert(key, value.clone().into());
757 }
758 toml_edit::Value::InlineTable(table)
759}
760
761#[allow(
766 clippy::too_many_lines,
767 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
768)]
769fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
770 let trunk = config
774 .git
775 .trunk
776 .clone()
777 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
778 let technologies = config
779 .profile
780 .technologies
781 .clone()
782 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
783 let forge = config
784 .profile
785 .forge
786 .clone()
787 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
788 let mode = config
789 .profile
790 .release
791 .mode
792 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
793 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
794 (
795 "RK_CONFIG_SCHEMA_VERSION",
796 Some(config.schema_version.into()),
797 ),
798 (
799 "RK_CONFIG_PROJECT_REPO",
800 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
801 ),
802 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
803 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
804 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
805 (
806 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
807 config
808 .profile
809 .release
810 .driver
811 .clone()
812 .map(toml_edit::Value::from),
813 ),
814 (
815 "RK_CONFIG_PROFILE_RELEASE_STYLE",
816 config
817 .profile
818 .release
819 .style
820 .map(|style| style.as_str().into()),
821 ),
822 (
823 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
824 config
825 .profile
826 .release
827 .line_prefix
828 .clone()
829 .map(toml_edit::Value::from),
830 ),
831 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
832 (
833 "RK_CONFIG_GIT_CHECKOUT_MODE",
834 Some(
835 config
836 .git
837 .checkout_mode
838 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
839 .as_str()
840 .into(),
841 ),
842 ),
843 (
844 "RK_CONFIG_GIT_INTEGRATION",
845 Some(
846 config
847 .git
848 .integration
849 .ok_or_else(|| invalid("git.integration is unresolved"))?
850 .as_str()
851 .into(),
852 ),
853 ),
854 (
855 "RK_CONFIG_GIT_MERGE_FORM",
856 Some(
857 config
858 .git
859 .merge_form
860 .ok_or_else(|| invalid("git.merge_form is unresolved"))?
861 .as_str()
862 .into(),
863 ),
864 ),
865 (
866 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
867 Some(
868 config
869 .capabilities
870 .nix_packaging
871 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
872 .into(),
873 ),
874 ),
875 (
876 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
877 Some(
878 config
879 .capabilities
880 .reporting_policy
881 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
882 .into(),
883 ),
884 ),
885 (
886 "RK_CONFIG_CAPABILITIES_SCORECARD",
887 Some(
888 config
889 .capabilities
890 .scorecard
891 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
892 .into(),
893 ),
894 ),
895 (
896 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
897 Some(
898 config
899 .capabilities
900 .code_scanning
901 .clone()
902 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
903 .into(),
904 ),
905 ),
906 (
907 "RK_CONFIG_CAPABILITIES_WORKFLOW_GUARDS",
908 Some(
909 config
910 .capabilities
911 .workflow_guards
912 .ok_or_else(|| invalid("capabilities.workflow_guards is unresolved"))?
913 .into(),
914 ),
915 ),
916 (
917 "RK_CONFIG_SECURITY_ADVISORIES",
918 Some(config.security.advisories.clone().into()),
919 ),
920 (
921 "RK_CONFIG_SECURITY_CONTACT",
922 Some(config.security.contact.clone().unwrap_or_default().into()),
923 ),
924 (
925 "RK_CONFIG_SECURITY_RESPONSE",
926 Some(
927 config
928 .security
929 .response
930 .clone()
931 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
932 .into(),
933 ),
934 ),
935 (
936 "RK_CONFIG_SETUP_REQUIRED_CHECK",
937 Some(config.setup.required_check.clone().into()),
938 ),
939 (
940 "RK_CONFIG_SETUP_REQUIRED_WORKFLOW",
941 Some(config.setup.required_workflow.clone().into()),
942 ),
943 (
944 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
945 Some(array(&config.setup.retired_branches)),
946 ),
947 (
948 "RK_CONFIG_SETUP_RELEASE_LINES",
949 Some(config.setup.release_lines.into()),
950 ),
951 (
952 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
953 Some(inline(&config.setup.excluded_steps)),
954 ),
955 (
956 "RK_CONFIG_SETUP_BOT_APP_ID",
957 Some(config.setup.bot.app_id.clone().into()),
958 ),
959 (
960 "RK_CONFIG_ACQUISITION_SOURCE",
961 config
962 .acquisition
963 .as_ref()
964 .map(|answer| answer.source.as_str().into()),
965 ),
966 (
967 "RK_CONFIG_ACQUISITION_MINIMUM",
968 config
969 .acquisition
970 .as_ref()
971 .map(|answer| answer.minimum.clone().into()),
972 ),
973 ];
974 fields.extend(
975 protection_fields(&config.protection, trunk.as_str())
976 .into_iter()
977 .map(|(token, value)| (token, Some(value))),
978 );
979 if mode == crate::profile::ReleaseMode::None {
985 let reporting = config.capabilities.reporting_policy.unwrap_or(false);
986 for (token, value) in &mut fields {
987 if RELEASE_ONLY_TOKENS.contains(token)
988 || (!reporting && SECURITY_TOKENS.contains(token))
989 {
990 *value = None;
991 }
992 }
993 }
994 Ok(fields)
995}
996
997const RELEASE_ONLY_TOKENS: [&str; 4] = [
1003 "RK_CONFIG_PROTECTION_TAG_RULESET",
1004 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1005 "RK_CONFIG_PROTECTION_LINES_RULESET",
1006 "RK_CONFIG_SETUP_BOT_APP_ID",
1007];
1008
1009const SECURITY_TOKENS: [&str; 3] = [
1012 "RK_CONFIG_SECURITY_ADVISORIES",
1013 "RK_CONFIG_SECURITY_CONTACT",
1014 "RK_CONFIG_SECURITY_RESPONSE",
1015];
1016
1017fn render(config: &Config) -> Result<Vec<u8>, RkError> {
1018 let fields = fields(config)?;
1019 let template = crate::embedded::BLOCKS
1020 .get_file("target-config.toml.in")
1021 .and_then(include_dir::File::contents_utf8)
1022 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
1023 let lines: Vec<&str> = template.split_inclusive('\n').collect();
1029 let dropped: Vec<bool> = lines
1030 .iter()
1031 .map(|line| {
1032 matches!(
1033 fields.iter().find(|(token, _)| line.contains(token)),
1034 Some((_, None))
1035 )
1036 })
1037 .collect();
1038 let mut bytes = Vec::new();
1039 let mut at = 0;
1040 while at < lines.len() {
1041 let line = lines[at];
1042 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
1043 let mut end = at + 1;
1044 let mut keeps = false;
1045 while end < lines.len()
1046 && !(lines[end].trim_start().starts_with('[')
1047 && lines[end].trim_end().ends_with(']'))
1048 {
1049 keeps |=
1050 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
1051 end += 1;
1052 }
1053 if !keeps {
1054 while bytes.last() == Some(&b'\n')
1056 && bytes.len() >= 2
1057 && bytes[bytes.len() - 2] == b'\n'
1058 {
1059 bytes.pop();
1060 }
1061 at = end;
1062 continue;
1063 }
1064 }
1065 if !dropped[at] {
1066 match fields.iter().find(|(token, _)| line.contains(token)) {
1067 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
1068 line.as_bytes(),
1069 token.as_bytes(),
1070 value.to_string().as_bytes(),
1071 )),
1072 Some((_, None)) => {}
1073 None => bytes.extend_from_slice(line.as_bytes()),
1074 }
1075 }
1076 at += 1;
1077 }
1078 Ok(bytes)
1079}
1080
1081fn protection_fields(
1082 protection: &Protection,
1083 trunk: &str,
1084) -> Vec<(&'static str, toml_edit::Value)> {
1085 vec![
1086 (
1087 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
1088 protection.trunk_ruleset(trunk).into(),
1089 ),
1090 (
1091 "RK_CONFIG_PROTECTION_SAFETY_RULESET",
1092 protection.safety_ruleset(trunk).into(),
1093 ),
1094 (
1095 "RK_CONFIG_PROTECTION_TAG_RULESET",
1096 protection.tag_ruleset.clone().into(),
1097 ),
1098 (
1099 "RK_CONFIG_PROTECTION_LINES_RULESET",
1100 protection.lines_ruleset.clone().into(),
1101 ),
1102 (
1103 "RK_CONFIG_PROTECTION_TITLE_CHECK",
1104 protection.title_check.clone().into(),
1105 ),
1106 (
1107 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1108 protection.tag_pattern.clone().into(),
1109 ),
1110 (
1111 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
1112 array(&protection.bypass_actors),
1113 ),
1114 (
1115 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
1116 array(&protection.allowed_merge_methods),
1117 ),
1118 (
1119 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
1120 protection.strict_required_status_checks.into(),
1121 ),
1122 (
1123 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
1124 array(&protection.owned_trunk_rules),
1125 ),
1126 (
1127 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
1128 protection.required_approving_review_count.into(),
1129 ),
1130 (
1131 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
1132 protection.dismiss_stale_reviews_on_push.into(),
1133 ),
1134 (
1135 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
1136 protection.require_code_owner_review.into(),
1137 ),
1138 (
1139 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
1140 protection.require_last_push_approval.into(),
1141 ),
1142 (
1143 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
1144 protection.github.squash_title_source.clone().into(),
1145 ),
1146 (
1147 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
1148 protection.github.squash_body_source.clone().into(),
1149 ),
1150 (
1151 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
1152 protection.gitlab.merge_method.clone().into(),
1153 ),
1154 (
1155 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
1156 protection.gitlab.squash_option.clone().into(),
1157 ),
1158 (
1159 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
1160 protection.gitlab.squash_commit_template.clone().into(),
1161 ),
1162 (
1163 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
1164 protection.gitlab.push_access_level.into(),
1165 ),
1166 (
1167 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
1168 protection.gitlab.merge_access_level.into(),
1169 ),
1170 ]
1171}
1172
1173const DERIVED_POLICY_KEYS: [&str; 5] = [
1184 "protection.bypass_actors",
1185 "protection.owned_trunk_rules",
1186 "protection.gitlab.push_access_level",
1187 "protection.allowed_merge_methods",
1188 "protection.gitlab.squash_option",
1189];
1190
1191const PARAMETER_KEYS: [&str; 26] = [
1193 "project.repo",
1194 "profile.technologies",
1195 "profile.forge",
1196 "profile.release.mode",
1197 "profile.release.driver",
1198 "profile.release.style",
1199 "profile.release.line_prefix",
1200 "git.trunk",
1201 "git.checkout_mode",
1202 "git.integration",
1203 "git.merge_form",
1204 "protection.allowed_merge_methods",
1207 "protection.gitlab.squash_option",
1208 "protection.bypass_actors",
1212 "protection.owned_trunk_rules",
1213 "protection.gitlab.push_access_level",
1214 "capabilities.nix_packaging",
1215 "capabilities.reporting_policy",
1216 "capabilities.scorecard",
1217 "capabilities.code_scanning",
1218 "capabilities.workflow_guards",
1219 "security.contact",
1220 "security.response",
1221 "setup.required_check",
1224 "setup.required_workflow",
1225 "schema_version",
1226];
1227
1228pub fn with_acquisition(target: &Path, answer: &Acquisition) -> Result<(String, bool), RkError> {
1240 let path = target.join(CONFIG_PATH);
1241 let text = match std::fs::read_to_string(&path) {
1242 Ok(text) => text,
1243 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1244 Err(error) => return Err(error.into()),
1245 };
1246 if text.is_empty() {
1247 let template = crate::embedded::BLOCKS
1248 .get_file("acquisition-config.toml.in")
1249 .and_then(include_dir::File::contents_utf8)
1250 .ok_or_else(|| invalid("the binary lacks its acquisition template"))?;
1251 let mut fresh = template.as_bytes().to_vec();
1252 for (token, value) in [
1253 (
1254 "RK_CONFIG_SCHEMA_VERSION",
1255 toml_edit::Value::from(SCHEMA_VERSION),
1256 ),
1257 (
1258 "RK_CONFIG_ACQUISITION_SOURCE",
1259 toml_edit::Value::from(answer.source.as_str()),
1260 ),
1261 (
1262 "RK_CONFIG_ACQUISITION_MINIMUM",
1263 toml_edit::Value::from(answer.minimum.clone()),
1264 ),
1265 ] {
1266 fresh =
1267 crate::landing::substitute(&fresh, token.as_bytes(), value.to_string().as_bytes());
1268 }
1269 let fresh = String::from_utf8(fresh).map_err(|error| invalid(error.to_string()))?;
1270 parse(&fresh)?;
1271 return Ok((fresh, true));
1272 }
1273 let held = parse(&text)?;
1274 if held.acquisition.as_ref() == Some(answer) {
1275 return Ok((text, false));
1276 }
1277 let text = current_text(&text)?;
1278 let mut document = text
1279 .parse::<toml_edit::DocumentMut>()
1280 .map_err(|error| invalid(error.to_string()))?;
1281 if !document.contains_table("acquisition") {
1282 let mut table = toml_edit::Table::new();
1283 table.decor_mut().set_prefix("\n");
1284 document.insert("acquisition", toml_edit::Item::Table(table));
1285 }
1286 let table = document
1287 .get_mut("acquisition")
1288 .and_then(toml_edit::Item::as_table_mut)
1289 .ok_or_else(|| invalid("acquisition must be a table"))?;
1290 for (key, value, note) in [
1291 (
1292 "source",
1293 answer.source.as_str().to_owned(),
1294 " P: host where every host installs rk itself; absent where a manager file pins it",
1295 ),
1296 (
1297 "minimum",
1298 answer.minimum.clone(),
1299 " P: the lowest rk release a host may run against this project",
1300 ),
1301 ] {
1302 let kept = table
1303 .get(key)
1304 .and_then(toml_edit::Item::as_value)
1305 .map(|held| held.decor().clone());
1306 let mut value = toml_edit::Value::from(value);
1307 match kept {
1308 Some(decor) => *value.decor_mut() = decor,
1309 None => value.decor_mut().set_suffix(format!(" #{note}")),
1310 }
1311 table.insert(key, toml_edit::Item::Value(value));
1312 }
1313 let next = document.to_string();
1314 parse(&next)?;
1315 Ok((next, true))
1316}
1317
1318impl Config {
1319 #[must_use]
1322 pub fn states_only_a_migration(&self) -> bool {
1323 self.migration.is_some()
1324 && Self {
1325 migration: None,
1326 ..self.clone()
1327 } == Self::default()
1328 }
1329}
1330
1331pub fn with_migration(text: &str, id: Option<&str>) -> Result<String, RkError> {
1338 let mut document = text
1339 .parse::<toml_edit::DocumentMut>()
1340 .map_err(|error| invalid(error.to_string()))?;
1341 let root = document.as_table_mut();
1342 match id {
1343 Some(id) => {
1344 root.insert("migration", toml_edit::value(id));
1345 root.sort_values_by(|a, _, b, _| {
1347 let rank = |key: &toml_edit::Key| match key.get() {
1348 "schema_version" => 0,
1349 "migration" => 1,
1350 _ => 2,
1351 };
1352 rank(a).cmp(&rank(b))
1353 });
1354 }
1355 None => {
1356 root.remove("migration");
1357 }
1358 }
1359 let next = document.to_string();
1360 parse(&next)?;
1361 Ok(next)
1362}
1363
1364pub fn check_writable(target: &Path) -> Result<(), RkError> {
1374 let file = target.join(CONFIG_PATH);
1375 for (path, directory) in [(file.parent(), true), (Some(file.as_path()), false)] {
1376 let Some(path) = path else { continue };
1377 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1378 continue;
1379 };
1380 let kind = metadata.file_type();
1381 let fits = if directory {
1382 kind.is_dir()
1383 } else {
1384 kind.is_file()
1385 };
1386 if !fits {
1387 return Err(RkError::refusal(
1388 Diagnostic::new(
1389 Reason::StateDrift,
1390 format!(
1391 "{} is a symlink or not a regular {}, so a configuration write would not stay in the target",
1392 path.display(),
1393 if directory { "directory" } else { "file" }
1394 ),
1395 )
1396 .expected(format!("{CONFIG_PATH} as a regular file in a regular directory"))
1397 .action("replace the symlink with the directory or file it stands for")
1398 .target_state("unchanged"),
1399 ));
1400 }
1401 }
1402 Ok(())
1403}
1404
1405pub fn set_migration(target: &Path, id: Option<&str>) -> Result<(), RkError> {
1413 check_writable(target)?;
1414 let path = target.join(CONFIG_PATH);
1415 let text = match std::fs::read_to_string(&path) {
1416 Ok(text) => text,
1417 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1418 if id.is_none() {
1419 return Ok(());
1420 }
1421 format!("schema_version = {SCHEMA_VERSION}\n")
1422 }
1423 Err(error) => return Err(error.into()),
1424 };
1425 let next = with_migration(¤t_text(&text)?, id)?;
1426 write_checked(target, next.as_bytes())
1427}
1428
1429pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
1435 let path = target.join(CONFIG_PATH);
1436 let text = std::fs::read_to_string(&path)?;
1437 let next = rewrite_text(&text, key, Some(value))?;
1438 write_checked(target, next.as_bytes())
1439}
1440
1441fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
1444 rewrite_all(text, vec![(key, value)])
1445}
1446
1447fn rewrite_all(
1455 text: &str,
1456 values: Vec<(&str, Option<toml_edit::Value>)>,
1457) -> Result<String, RkError> {
1458 for (key, _) in &values {
1459 if !PARAMETER_KEYS.contains(key) {
1460 return Err(invalid(format!("{key} is not a landing parameter")));
1461 }
1462 }
1463 parse(text)?;
1464 let text = current_text(text)?;
1465 let mut document = text
1466 .parse::<toml_edit::DocumentMut>()
1467 .map_err(|error| invalid(error.to_string()))?;
1468 let mut changed = false;
1469 let mut emptied: Vec<&str> = Vec::new();
1472 for (key, value) in values {
1473 let removing = value.is_none();
1474 let applied = apply_key(&mut document, key, value)?;
1475 changed |= applied;
1476 if applied
1477 && removing
1478 && let Some(parent) = key.split('.').next()
1479 {
1480 emptied.push(parent);
1481 }
1482 }
1483 changed |= prune_empty_tables(&mut document, &emptied);
1484 changed |= migrate::refresh_comments(&mut document);
1491 if !changed {
1492 return Ok(text);
1493 }
1494 let next = document.to_string();
1495 parse(&next)?;
1496 Ok(next)
1497}
1498
1499pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1518 let order: Vec<String> = document
1519 .as_table()
1520 .iter()
1521 .map(|(key, _)| key.to_owned())
1522 .collect();
1523 let mut changed = false;
1524 for (index, name) in order.iter().enumerate() {
1525 if !names.contains(&name.as_str())
1526 || !document
1527 .get(name)
1528 .and_then(toml_edit::Item::as_table)
1529 .is_some_and(toml_edit::Table::is_empty)
1530 {
1531 continue;
1532 }
1533 let carried = document
1534 .get(name)
1535 .and_then(toml_edit::Item::as_table)
1536 .and_then(|table| carried_comment(table.decor()));
1537 document.remove(name);
1538 changed = true;
1539 let Some(carried) = carried else { continue };
1540 let next = order
1544 .iter()
1545 .skip(index + 1)
1546 .find(|name| {
1547 document
1548 .get(name)
1549 .and_then(toml_edit::Item::as_table)
1550 .is_some_and(|table| !table.is_implicit())
1551 })
1552 .cloned();
1553 if let Some(next) = next
1554 && let Some(table) = document
1555 .get_mut(&next)
1556 .and_then(toml_edit::Item::as_table_mut)
1557 {
1558 let existing = table
1559 .decor()
1560 .prefix()
1561 .and_then(toml_edit::RawString::as_str)
1562 .unwrap_or_default()
1563 .trim_start_matches('\n')
1564 .to_owned();
1565 table
1566 .decor_mut()
1567 .set_prefix(format!("\n{carried}{existing}"));
1568 } else {
1569 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1570 if !trailing.is_empty() && !trailing.ends_with('\n') {
1571 trailing.push('\n');
1572 }
1573 trailing.push_str(&carried);
1574 document.set_trailing(trailing);
1575 }
1576 }
1577 changed
1578}
1579
1580fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1587 let mut lines = String::new();
1588 for raw in [decor.prefix(), decor.suffix()] {
1589 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1590 continue;
1591 };
1592 for line in text
1593 .lines()
1594 .map(str::trim)
1595 .filter(|line| line.starts_with('#'))
1596 .filter(|line| !is_template_comment(line))
1597 {
1598 lines.push_str(line);
1599 lines.push('\n');
1600 }
1601 }
1602 (!lines.is_empty()).then_some(lines)
1603}
1604
1605fn is_template_comment(line: &str) -> bool {
1614 let rest = line.trim_start_matches('#').trim_start();
1615 ["P:", "N:", "F:"]
1616 .iter()
1617 .any(|marker| rest.starts_with(marker))
1618}
1619
1620pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1628 let first = document
1629 .as_table()
1630 .iter()
1631 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1632 .map(|(name, _)| name.to_owned());
1633 if let Some(first) = first
1634 && let Some(table) = document
1635 .get_mut(&first)
1636 .and_then(toml_edit::Item::as_table_mut)
1637 {
1638 let existing = table
1639 .decor()
1640 .prefix()
1641 .and_then(toml_edit::RawString::as_str)
1642 .unwrap_or_default()
1643 .trim_start_matches('\n')
1644 .to_owned();
1645 table
1646 .decor_mut()
1647 .set_prefix(format!("\n{carried}{existing}"));
1648 return;
1649 }
1650 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1651 if !trailing.is_empty() && !trailing.ends_with('\n') {
1652 trailing.push('\n');
1653 }
1654 trailing.push_str(carried);
1655 document.set_trailing(trailing);
1656}
1657
1658pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1664 let (name, _) = table.get_key_value(key)?;
1665 carried_comment(name.leaf_decor())
1666}
1667
1668pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1670 let Some(mut name) = table.key_mut(key) else {
1671 return;
1672 };
1673 let decor = name.leaf_decor_mut();
1674 let existing = decor
1675 .prefix()
1676 .and_then(toml_edit::RawString::as_str)
1677 .unwrap_or_default()
1678 .trim_start_matches('\n')
1679 .to_owned();
1680 decor.set_prefix(format!("\n{carried}{existing}"));
1681}
1682
1683#[must_use]
1685pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1686 known
1687 .iter()
1688 .min_by_key(|name| distance(unknown, name))
1689 .copied()
1690}
1691
1692pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1695 let carried = carried_comment(table.decor())?;
1696 table.decor_mut().set_prefix("\n");
1697 Some(carried)
1698}
1699
1700pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1711 let carried = table.get_key_value(key).and_then(|(name, item)| {
1712 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1713 if let Some(value) = item.as_value()
1714 && let Some(more) = carried_comment(value.decor())
1715 {
1716 lines.push_str(&more);
1717 }
1718 (!lines.is_empty()).then_some(lines)
1719 });
1720 let removed = table.remove(key).is_some();
1721 if let Some(carried) = carried {
1722 let existing = table
1723 .decor()
1724 .prefix()
1725 .and_then(toml_edit::RawString::as_str)
1726 .unwrap_or_default()
1727 .trim_start_matches('\n')
1728 .to_owned();
1729 table
1730 .decor_mut()
1731 .set_prefix(format!("\n{carried}{existing}"));
1732 }
1733 removed
1734}
1735
1736fn apply_key(
1739 document: &mut toml_edit::DocumentMut,
1740 key: &str,
1741 value: Option<toml_edit::Value>,
1742) -> Result<bool, RkError> {
1743 let segments: Vec<&str> = key.split('.').collect();
1744 let Some((last, parents)) = segments.split_last() else {
1747 return Err(invalid(format!("{key} names no key")));
1748 };
1749 let Some(mut value) = value else {
1750 let mut item = document.as_item_mut();
1751 for segment in parents {
1752 if item.get(segment).is_none() {
1753 return Ok(false);
1754 }
1755 item = &mut item[segment];
1756 }
1757 let removed = item
1758 .as_table_mut()
1759 .is_some_and(|table| take_comments(table, last));
1760 return Ok(removed);
1761 };
1762 let mut item = document.as_item_mut();
1763 for segment in parents {
1764 if item.get(segment).is_none() {
1765 let mut table = toml_edit::Table::new();
1766 table.set_implicit(true);
1767 item[segment] = toml_edit::Item::Table(table);
1768 }
1769 item = &mut item[segment];
1770 }
1771 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1772 if same_value(old, &value) {
1773 return Ok(false);
1774 }
1775 *value.decor_mut() = old.decor().clone();
1776 } else if let Some(comment) = migrate::template_comment(&segments) {
1777 value.decor_mut().set_suffix(comment);
1783 }
1784 item[last] = toml_edit::Item::Value(value);
1785 Ok(true)
1786}
1787
1788fn same_value(old: &toml_edit::Value, new: &toml_edit::Value) -> bool {
1792 let parsed = |value: &toml_edit::Value| {
1793 format!("v = {}", value.to_string().trim()).parse::<toml::Table>()
1794 };
1795 matches!((parsed(old), parsed(new)), (Ok(old), Ok(new)) if old == new)
1796}
1797
1798#[derive(Debug, Clone, serde::Serialize)]
1800pub struct Plan {
1801 pub action: &'static str,
1803 pub changes: Vec<String>,
1805 pub content: String,
1807}
1808
1809impl Plan {
1810 pub fn new(
1815 target: &Path,
1816 params: &crate::landing::Params,
1817 existing: Option<&Config>,
1818 record: Option<&crate::landing::manifest::Manifest>,
1819 ) -> Result<Self, RkError> {
1820 let text = existing
1821 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1822 .transpose()?;
1823 Self::compose(text.as_deref(), params, existing, record)
1824 }
1825
1826 pub fn compose(
1833 text: Option<&str>,
1834 params: &crate::landing::Params,
1835 existing: Option<&Config>,
1836 record: Option<&crate::landing::manifest::Manifest>,
1837 ) -> Result<Self, RkError> {
1838 let mut resolved = existing.cloned().unwrap_or_default();
1839 resolved.schema_version = SCHEMA_VERSION;
1840 params.repo().clone_into(&mut resolved.project.repo);
1841 resolved.profile = Profile {
1842 technologies: Some(params.technologies().to_vec()),
1843 forge: Some(params.forge().unwrap_or_default().to_owned()),
1844 release: Release {
1845 mode: Some(params.release_mode()),
1846 driver: params.driver().map(str::to_owned),
1847 style: params.style(),
1848 line_prefix: params.profile().release.line_prefix.clone(),
1849 },
1850 };
1851 resolved.git = Git {
1852 trunk: Some(params.trunk().to_owned()),
1853 checkout_mode: Some(params.checkout_mode()),
1854 integration: Some(params.integration()),
1855 merge_form: Some(params.merge_form()),
1856 };
1857 resolved.protection = with_merge_form(
1858 &protection_for(
1859 &resolved.protection,
1860 params.integration(),
1861 existing.is_none(),
1862 ),
1863 params.merge_form(),
1864 );
1865 resolved.capabilities = Capabilities {
1866 nix_packaging: Some(params.nix_packaging()),
1867 reporting_policy: Some(params.reporting_policy()),
1868 scorecard: Some(params.scorecard()),
1869 code_scanning: Some(
1870 params
1871 .code_scanning()
1872 .map_or("off", crate::landing::Provider::as_str)
1873 .to_owned(),
1874 ),
1875 workflow_guards: Some(params.workflow_guards()),
1876 };
1877 if params.release_mode() != crate::profile::ReleaseMode::None || params.reporting_policy() {
1881 resolved.security.contact = Some(params.security_contact().to_owned());
1882 resolved.security.response = Some(params.security_response().to_owned());
1883 }
1884 params
1885 .required_check()
1886 .clone_into(&mut resolved.setup.required_check);
1887 params
1888 .required_workflow()
1889 .clone_into(&mut resolved.setup.required_workflow);
1890 let bare = existing.is_some_and(Config::states_only_a_migration);
1895 let content = if let Some(text) = text.filter(|_| existing.is_some() && !bare) {
1896 rewrite_all(text, parameter_values(&resolved))?
1897 } else {
1898 let rendered =
1899 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?;
1900 with_migration(&rendered, resolved.migration.as_deref())?
1901 };
1902 parse(&content)?;
1903 Ok(Self {
1904 action: if existing.is_some() {
1905 "updated"
1906 } else {
1907 "added"
1908 },
1909 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1910 content,
1911 })
1912 }
1913
1914 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1920 write_checked(target, self.content.as_bytes())
1921 }
1922}
1923
1924#[allow(
1926 clippy::too_many_lines,
1927 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"
1928)]
1929fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1930 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1931 "project.repo",
1932 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1933 )];
1934 if let Some(list) = &config.profile.technologies {
1935 values.push(("profile.technologies", Some(array(list))));
1936 }
1937 if let Some(forge) = config.profile.forge.clone() {
1938 values.push(("profile.forge", Some(forge.into())));
1939 }
1940 if let Some(mode) = config.profile.release.mode {
1941 values.push(("profile.release.mode", Some(mode.as_str().into())));
1942 values.push((
1943 "profile.release.driver",
1944 config
1945 .profile
1946 .release
1947 .driver
1948 .clone()
1949 .map(toml_edit::Value::from),
1950 ));
1951 values.push((
1952 "profile.release.style",
1953 config
1954 .profile
1955 .release
1956 .style
1957 .map(|style| style.as_str().into()),
1958 ));
1959 values.push((
1960 "profile.release.line_prefix",
1961 config
1962 .profile
1963 .release
1964 .line_prefix
1965 .clone()
1966 .map(toml_edit::Value::from),
1967 ));
1968 }
1969 if let Some(trunk) = config.git.trunk.clone() {
1970 values.push(("git.trunk", Some(trunk.into())));
1971 }
1972 if let Some(mode) = config.git.checkout_mode {
1973 values.push(("git.checkout_mode", Some(mode.as_str().into())));
1974 }
1975 if let Some(mode) = config.git.integration {
1976 values.push(("git.integration", Some(mode.as_str().into())));
1977 let mut bypass = toml_edit::Array::new();
1981 for actor in &config.protection.bypass_actors {
1982 bypass.push(actor.as_str());
1983 }
1984 values.push((
1985 "protection.bypass_actors",
1986 Some(toml_edit::Value::Array(bypass)),
1987 ));
1988 let mut rules = toml_edit::Array::new();
1989 for rule in &config.protection.owned_trunk_rules {
1990 rules.push(rule.as_str());
1991 }
1992 values.push((
1993 "protection.owned_trunk_rules",
1994 Some(toml_edit::Value::Array(rules)),
1995 ));
1996 values.push((
1997 "protection.gitlab.push_access_level",
1998 Some(config.protection.gitlab.push_access_level.into()),
1999 ));
2000 }
2001 if let Some(form) = config.git.merge_form {
2002 values.push(("git.merge_form", Some(form.as_str().into())));
2003 let mut methods = toml_edit::Array::new();
2008 for method in &config.protection.allowed_merge_methods {
2009 methods.push(method.as_str());
2010 }
2011 values.push((
2012 "protection.allowed_merge_methods",
2013 Some(toml_edit::Value::Array(methods)),
2014 ));
2015 values.push((
2016 "protection.gitlab.squash_option",
2017 Some(config.protection.gitlab.squash_option.clone().into()),
2018 ));
2019 }
2020 if let Some(value) = config.capabilities.nix_packaging {
2021 values.push(("capabilities.nix_packaging", Some(value.into())));
2022 }
2023 if let Some(value) = config.capabilities.reporting_policy {
2024 values.push(("capabilities.reporting_policy", Some(value.into())));
2025 }
2026 if let Some(value) = config.capabilities.scorecard {
2027 values.push(("capabilities.scorecard", Some(value.into())));
2028 }
2029 if let Some(value) = config.capabilities.code_scanning.clone() {
2030 values.push(("capabilities.code_scanning", Some(value.into())));
2031 }
2032 if let Some(value) = config.capabilities.workflow_guards {
2033 values.push(("capabilities.workflow_guards", Some(value.into())));
2034 }
2035 if let Some(value) = config.security.contact.clone() {
2038 values.push(("security.contact", Some(value.into())));
2039 }
2040 if let Some(value) = config.security.response.clone() {
2041 values.push(("security.response", Some(value.into())));
2042 }
2043 values.push((
2048 "setup.required_check",
2049 Some(config.setup.required_check.clone().into()),
2050 ));
2051 values.push((
2052 "setup.required_workflow",
2053 Some(config.setup.required_workflow.clone().into()),
2054 ));
2055 values
2056}
2057
2058#[must_use]
2060pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
2061 let recorded = {
2062 let params = crate::landing::Params::from_record(record);
2063 Plan::compose(None, ¶ms, None, None)
2064 .ok()
2065 .and_then(|plan| parse(&plan.content).ok())
2066 };
2067 let Some(recorded) = recorded else {
2068 return Vec::new();
2069 };
2070 let render = |value: &Option<toml_edit::Value>| {
2071 value.as_ref().map_or_else(
2072 || "<absent>".to_owned(),
2073 |value| value.to_string().trim().to_owned(),
2074 )
2075 };
2076 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
2077 .iter()
2078 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2079 .map(|(key, value)| (*key, render(value)))
2080 .collect();
2081 let mut keys: Vec<String> = parameter_values(config)
2082 .into_iter()
2083 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2084 .map(|(key, value)| (key, render(&value)))
2085 .filter(|(key, value)| {
2086 baseline
2087 .iter()
2088 .any(|(other, old)| key == other && value != old)
2089 })
2090 .map(|(key, _)| key.to_owned())
2091 .collect();
2092 if let Some(answer) = &config.dev_environment
2096 && answer.clone().answered() != record.parameters.dev_environment
2097 {
2098 keys.push("dev_environment".to_owned());
2099 }
2100 keys
2101}
2102
2103#[must_use]
2111pub(crate) fn local_protection() -> Protection {
2112 const MAINTAINER: i64 = 40;
2114 Protection {
2115 bypass_actors: vec![LOCAL_GITHUB_BYPASS.into()],
2116 gitlab: Gitlab {
2117 push_access_level: MAINTAINER,
2118 ..Gitlab::default()
2119 },
2120 ..Protection::default()
2121 }
2122}
2123
2124#[must_use]
2131pub const fn merge_values(form: MergeForm) -> (&'static str, &'static str) {
2132 match form {
2133 MergeForm::Squash => ("squash", "always"),
2134 MergeForm::Rebase => ("rebase", "never"),
2135 }
2136}
2137
2138#[must_use]
2143pub fn with_merge_form(policy: &Protection, form: MergeForm) -> Protection {
2144 let (method, squash_option) = merge_values(form);
2145 let mut next = policy.clone();
2146 next.allowed_merge_methods = vec![method.to_owned()];
2147 squash_option.clone_into(&mut next.gitlab.squash_option);
2148 next
2149}
2150
2151#[must_use]
2154pub(crate) fn legacy_local_protection(policy: &Protection) -> bool {
2155 policy.bypass_actors.is_empty()
2156 && policy.owned_trunk_rules == ["deletion", "non_fast_forward"]
2157 && policy.gitlab.push_access_level >= 40
2158}
2159
2160#[must_use]
2182fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
2183 let forge = Protection::default();
2184 let local = local_protection();
2185 let tuple = |policy: &Protection| {
2186 (
2187 policy.bypass_actors.clone(),
2188 policy.owned_trunk_rules.clone(),
2189 policy.gitlab.push_access_level,
2190 )
2191 };
2192 let taken = fresh
2193 || tuple(held) == tuple(&forge)
2194 || tuple(held) == tuple(&local)
2195 || legacy_local_protection(held);
2196 if !taken {
2197 return held.clone();
2198 }
2199 let mut next = held.clone();
2200 let source = match integration {
2201 Integration::Forge => forge,
2202 Integration::Local => local,
2203 };
2204 next.bypass_actors = source.bypass_actors;
2205 next.owned_trunk_rules = source.owned_trunk_rules;
2206 next.gitlab.push_access_level = source.gitlab.push_access_level;
2207 next
2208}
2209
2210pub fn trunk_of(target: &Path) -> Result<String, RkError> {
2215 Ok(load(target)?
2216 .and_then(|config| config.git.trunk)
2217 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
2218}
2219
2220pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
2225 Ok(load(target)?
2226 .and_then(|config| config.profile.release.line_prefix)
2227 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
2228}
2229
2230#[cfg(test)]
2231mod tests {
2232 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, set_migration, trunk_of, write};
2233 use crate::landing::{CheckoutMode, Integration, MergeForm, Style};
2234 use crate::profile::ReleaseMode;
2235
2236 fn resolved_defaults() -> Config {
2239 Config {
2240 project: super::Project {
2241 repo: "acme/widget".into(),
2242 },
2243 profile: super::Profile {
2244 technologies: Some(vec!["rust".into()]),
2245 forge: Some("github".into()),
2246 release: super::Release {
2247 mode: Some(ReleaseMode::Automatic),
2248 driver: Some("rust".into()),
2249 style: Some(Style::Trunk),
2250 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
2251 },
2252 },
2253 git: super::Git {
2254 trunk: Some(super::TRUNK_DEFAULT.into()),
2255 checkout_mode: Some(CheckoutMode::LinkedWorktree),
2256 integration: Some(Integration::Local),
2257 merge_form: Some(MergeForm::Squash),
2258 },
2259 capabilities: super::Capabilities {
2260 nix_packaging: Some(false),
2261 reporting_policy: Some(true),
2262 scorecard: Some(false),
2263 code_scanning: Some("off".to_owned()),
2264 workflow_guards: Some(false),
2265 },
2266 security: super::Security {
2269 contact: Some(String::new()),
2270 response: Some(super::RESPONSE_DEFAULT.into()),
2271 ..super::Security::default()
2272 },
2273 protection: super::Protection {
2276 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
2277 safety_ruleset: Some(format!("{}-safety", super::TRUNK_DEFAULT)),
2278 ..super::local_protection()
2279 },
2280 ..Config::default()
2281 }
2282 }
2283
2284 #[test]
2287 fn a_symlinked_configuration_path_refuses_every_writer() {
2288 let outside = tempfile::tempdir().expect("an outside directory exists");
2289 let text = "schema_version = 2\n";
2290 std::fs::write(outside.path().join("config.toml"), text).expect("writes");
2291 let linked_dir = tempfile::tempdir().expect("a tempdir");
2292 std::os::unix::fs::symlink(outside.path(), linked_dir.path().join(".release-kit"))
2293 .expect("links the directory");
2294 let linked_file = tempfile::tempdir().expect("a tempdir");
2295 std::fs::create_dir_all(linked_file.path().join(".release-kit")).expect("mkdir");
2296 std::os::unix::fs::symlink(
2297 outside.path().join("config.toml"),
2298 linked_file.path().join(CONFIG_PATH),
2299 )
2300 .expect("links the file");
2301 for dir in [&linked_dir, &linked_file] {
2302 let target = dir.path();
2303 let refusals = [
2304 write(target, &resolved_defaults()).expect_err("write refuses"),
2305 rewrite_key(target, "git.trunk", "main".into()).expect_err("rewrite refuses"),
2306 set_migration(target, Some("20261009T140205Z")).expect_err("the key refuses"),
2307 ];
2308 for error in refusals {
2309 assert_eq!(error.exit_code(), 73, "{error}");
2310 assert!(error.to_string().contains("symlink"), "{error}");
2311 }
2312 }
2313 assert_eq!(
2314 std::fs::read_to_string(outside.path().join("config.toml")).expect("reads"),
2315 text
2316 );
2317 }
2318
2319 #[test]
2320 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
2321 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
2322 let explicit = parse(
2323 "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",
2324 )
2325 .expect("explicit defaults parse");
2326 assert_eq!(omitted.git, super::Git::default());
2327 assert_eq!(omitted.capabilities, super::Capabilities::default());
2328 assert_eq!(
2329 explicit.git.checkout_mode,
2330 Some(CheckoutMode::LinkedWorktree)
2331 );
2332 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
2333 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
2334 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
2335 assert_eq!(explicit.capabilities.scorecard, Some(false));
2336 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
2337 assert_ne!(omitted, explicit);
2338 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
2340 .expect("the older spelling reads");
2341 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
2342 }
2343
2344 #[test]
2348 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
2349 let dir = tempfile::tempdir().expect("a tempdir");
2350 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
2351 std::fs::write(
2352 dir.path().join(CONFIG_PATH),
2353 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
2354 )
2355 .expect("the config writes");
2356 let held = load(dir.path())
2357 .expect("the config reads")
2358 .expect("it is present");
2359 assert_eq!(held.setup.bot.app_id, "123");
2360 assert_eq!(
2361 held.setup.bot.installation_id,
2362 Some(0),
2363 "the key parses; nothing reads it"
2364 );
2365 }
2366
2367 #[test]
2369 fn a_schema_1_config_migrates_into_its_domains() {
2370 let held = parse(
2371 "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",
2372 )
2373 .expect("a schema 1 file reads");
2374 assert_eq!(held.schema_version, 2);
2375 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
2376 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
2377 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
2378 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
2379 assert_eq!(held.profile.release.style, Some(Style::Lines));
2380 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
2381 assert_eq!(held.git.trunk.as_deref(), Some("main"));
2382 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
2383 assert_eq!(held.capabilities.nix_packaging, Some(false));
2384 assert_eq!(held.capabilities.reporting_policy, Some(true));
2385 }
2386
2387 #[test]
2389 fn every_invalid_release_state_names_its_key() {
2390 for (text, key) in [
2391 (
2392 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
2393 "profile.release.style",
2394 ),
2395 (
2396 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
2397 "profile.release.driver",
2398 ),
2399 (
2400 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
2401 "profile.release.line_prefix",
2402 ),
2403 (
2404 "[profile]\ntechnologies = ['rust', 'rust']\n",
2405 "profile.technologies",
2406 ),
2407 (
2408 "[profile]\ntechnologies = ['Rust!']\n",
2409 "profile.technologies",
2410 ),
2411 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
2412 (
2413 "[profile.release]\nmode = 'manual'\n",
2414 "profile.release.mode",
2415 ),
2416 ] {
2417 let error = parse(&format!("schema_version = 2\n{text}"))
2418 .expect_err("an invalid release state refuses")
2419 .to_string();
2420 assert!(error.contains(key), "{key}: {error}");
2421 }
2422 }
2423
2424 #[test]
2428 fn a_newly_written_key_takes_the_templates_comment() {
2429 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
2430 let next = super::rewrite_text(
2431 text,
2432 "git.integration",
2433 Some(toml_edit::Value::from("forge")),
2434 )
2435 .expect("the key writes");
2436 assert!(
2437 next.contains("integration = \"forge\" # P: local or forge"),
2438 "{next}"
2439 );
2440 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
2442 .expect("the key writes");
2443 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
2444 }
2445
2446 #[test]
2447 fn the_landed_config_template_round_trips() {
2448 let dir = tempfile::tempdir().expect("a target exists");
2449 let mut config = Config::default();
2450 config.project.repo = "acme/nested/widget".into();
2451 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
2452 config.profile.forge = Some("gitlab".into());
2453 config.profile.release = super::Release {
2454 mode: Some(ReleaseMode::Automatic),
2455 driver: Some("bash".into()),
2456 style: Some(Style::Lines),
2457 line_prefix: Some("stable/".into()),
2458 };
2459 config.git.trunk = Some("main".into());
2460 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
2461 config.git.integration = Some(Integration::Forge);
2462 config.git.merge_form = Some(MergeForm::Rebase);
2463 config.protection = super::with_merge_form(&config.protection, MergeForm::Rebase);
2464 config.capabilities.nix_packaging = Some(true);
2465 config.capabilities.reporting_policy = Some(false);
2466 config.capabilities.scorecard = Some(true);
2467 config.capabilities.code_scanning = Some("semgrep".to_owned());
2468 config.capabilities.workflow_guards = Some(true);
2469 config.security.advisories =
2473 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
2474 config.security.contact = Some("security team, room 3 \"the vault\"".into());
2475 config.security.response = Some("14 business days".into());
2476 config.setup.required_check = "build / test".into();
2477 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
2478 config.setup.release_lines = true;
2479 config.setup.excluded_steps = [
2480 (
2481 "package-check".to_owned(),
2482 "nothing is published".to_owned(),
2483 ),
2484 (
2485 "protect-trunk".to_owned(),
2486 "this project merges \"locally\"".to_owned(),
2487 ),
2488 ]
2489 .into_iter()
2490 .collect();
2491 config.setup.bot.app_id = "123".into();
2492 config.protection.trunk_ruleset = Some("primary".into());
2493 config.protection.safety_ruleset = Some("guardrail".into());
2494 config.protection.tag_ruleset = "versions".into();
2495 config.protection.lines_ruleset = "maintenance".into();
2496 config.protection.title_check = "intent".into();
2497 config.protection.tag_pattern = "refs/tags/*".into();
2498 config
2499 .protection
2500 .owned_trunk_rules
2501 .push("required_signatures".into());
2502 config.protection.required_approving_review_count = 2;
2503 config.protection.dismiss_stale_reviews_on_push = true;
2504 config.protection.require_code_owner_review = true;
2505 config.protection.require_last_push_approval = true;
2506 config.protection.gitlab.squash_commit_template =
2507 "%{title}\n\nContext: %{description}".into();
2508 config.protection.gitlab.merge_access_level = 40;
2509 let mut release_less = resolved_defaults();
2512 release_less.project.repo = String::new();
2513 release_less.profile.technologies = Some(Vec::new());
2514 release_less.profile.forge = Some(String::new());
2515 release_less.profile.release = super::Release {
2516 mode: Some(ReleaseMode::None),
2517 driver: None,
2518 style: None,
2519 line_prefix: None,
2520 };
2521 release_less.capabilities.reporting_policy = Some(false);
2522 release_less.security.contact = None;
2525 release_less.security.response = None;
2526 for expected in [resolved_defaults(), config, release_less] {
2527 write(dir.path(), &expected).expect("the template renders");
2528 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
2529 let text =
2530 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2531 assert!(text.contains("# P: every technology"));
2532 assert!(text.contains("# F: invariant"));
2533 }
2534 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2535 assert!(
2536 !text.contains("style ="),
2537 "a none release writes no style: {text}"
2538 );
2539 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
2540 }
2541
2542 #[test]
2543 fn a_config_with_an_unknown_key_refuses_by_name() {
2544 for (table, typo, nearest) in [
2545 ("", "schemax_version", "schema_version"),
2546 ("git", "trunkx", "trunk"),
2547 ("profile.release", "stile", "style"),
2548 ("capabilities", "nix_packagingg", "nix_packaging"),
2549 ("security", "contactx", "contact"),
2550 ("setup", "required_checkx", "required_check"),
2551 ("setup.bot", "app_i", "app_id"),
2552 ("protection", "trunk_rulesett", "trunk_ruleset"),
2553 (
2554 "protection.github",
2555 "squash_body_sourcex",
2556 "squash_body_source",
2557 ),
2558 ("protection.gitlab", "squash_optionx", "squash_option"),
2559 ] {
2560 let header = if table.is_empty() {
2561 String::new()
2562 } else {
2563 format!("[{table}]\n")
2564 };
2565 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
2566 let error = parse(&text).expect_err("unknown keys refuse").to_string();
2567 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
2568 assert!(error.contains(expected), "{error}");
2569 }
2570 }
2571 }
2572
2573 #[test]
2577 fn an_exclusion_names_a_real_step_and_states_why() {
2578 for (text, expected) in [
2579 (
2580 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
2581 vec!["protect-trunkk", "nearest known step: protect-trunk"],
2582 ),
2583 (
2584 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
2585 vec!["protect-trunk", "no reason"],
2586 ),
2587 ] {
2588 let error = parse(&format!("schema_version = 2\n{text}"))
2589 .expect_err("the exclusion refuses")
2590 .to_string();
2591 for want in expected {
2592 assert!(error.contains(want), "{error}");
2593 }
2594 }
2595 let held = parse(
2596 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
2597 )
2598 .expect("a named step with a reason parses");
2599 assert_eq!(
2600 held.setup
2601 .excluded_steps
2602 .get("protect-trunk")
2603 .map(String::as_str),
2604 Some("we merge locally")
2605 );
2606 }
2607
2608 #[test]
2612 fn an_exclusion_does_not_lift_a_floor() {
2613 let error = parse(
2614 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
2615 )
2616 .expect_err("the floor binds an excluded step's keys too")
2617 .to_string();
2618 assert!(
2619 error.contains("protection.allowed_merge_methods"),
2620 "{error}"
2621 );
2622 }
2623
2624 #[test]
2625 fn a_config_at_an_unknown_schema_refuses() {
2626 for text in ["schema_version = 999", "schema_version = '2'", ""] {
2627 let error = parse(text)
2628 .expect_err("a schema must be declared and known")
2629 .to_string();
2630 assert!(
2631 error.contains(CONFIG_PATH) && error.contains("schema_version"),
2632 "{error}"
2633 );
2634 }
2635 }
2636
2637 #[test]
2638 fn an_unparsable_config_refuses_naming_the_position() {
2639 let error = parse("schema_version = 2\n[project\n")
2640 .expect_err("bad TOML refuses")
2641 .to_string();
2642 for expected in [CONFIG_PATH, "line 2", "column"] {
2643 assert!(error.contains(expected), "{error}");
2644 }
2645 }
2646
2647 #[test]
2648 fn an_absent_config_reads_as_none() {
2649 let dir = tempfile::tempdir().expect("a target exists");
2650 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2651 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2652 }
2653
2654 #[test]
2655 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2656 let dir = tempfile::tempdir().expect("a target exists");
2657 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2658 std::fs::write(
2659 dir.path().join(CONFIG_PATH),
2660 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2661 )
2662 .expect("a config exists");
2663 let error =
2664 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2665 assert_eq!(error.exit_code(), 73);
2666 assert!(
2667 error
2668 .to_string()
2669 .contains("protection.strict_required_status_checks")
2670 );
2671 }
2672
2673 #[test]
2676 fn an_equal_value_keeps_its_spelling() {
2677 let original = "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'tr\\u0075nk' # ours\n";
2678 let original = original.replace("'tr\\u0075nk'", "\"tr\\u0075nk\"");
2679 let text = super::rewrite_text(&original, "profile.release.style", Some("trunk".into()))
2680 .expect("the style rewrites");
2681 assert_eq!(text, original, "an equal value is rewritten");
2682 let quoted =
2683 "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk' # ours\n";
2684 let text = super::rewrite_text(quoted, "profile.release.style", Some("trunk".into()))
2685 .expect("the style rewrites");
2686 assert_eq!(
2687 text, quoted,
2688 "a literal string equal to the value is rewritten"
2689 );
2690 }
2691
2692 #[test]
2693 fn rewrite_key_preserves_comments() {
2694 let dir = tempfile::tempdir().expect("a target exists");
2695 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2696 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";
2697 let path = dir.path().join(CONFIG_PATH);
2698 std::fs::write(&path, original).expect("a config exists");
2699 rewrite_key(dir.path(), "profile.release.style", "lines".into())
2700 .expect("the style writes back");
2701 let text = std::fs::read_to_string(&path).expect("the text reads");
2702 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2703 assert_eq!(
2704 load(dir.path())
2705 .expect("the config reads")
2706 .expect("present")
2707 .profile
2708 .release
2709 .style,
2710 Some(Style::Lines)
2711 );
2712 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2713 .expect("an omitted table can be added");
2714 assert_eq!(
2715 load(dir.path())
2716 .expect("reads")
2717 .expect("present")
2718 .project
2719 .repo,
2720 "acme/widget"
2721 );
2722 rewrite_key(
2723 dir.path(),
2724 "security.contact",
2725 "security@acme.example".into(),
2726 )
2727 .expect("the contact is a landing parameter");
2728 rewrite_key(dir.path(), "security.response", "14 days".into())
2729 .expect("the response is a landing parameter");
2730 let held = load(dir.path()).expect("reads").expect("present");
2731 assert_eq!(
2732 held.security.contact.as_deref(),
2733 Some("security@acme.example")
2734 );
2735 assert_eq!(held.security.response.as_deref(), Some("14 days"));
2736 let text = std::fs::read_to_string(&path).expect("the text reads");
2737 assert!(text.contains("# keep me"), "the comment survives: {text}");
2738 let before = std::fs::read(&path).expect("the bytes read");
2739 for (key, value) in [
2740 ("security.advisories", "acme/private"),
2741 ("security.response", "90d"),
2742 ("profile.release.style", "unknown"),
2743 ] {
2744 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2745 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2746 }
2747 }
2748
2749 #[test]
2752 fn the_security_answers_are_held_to_their_grammar() {
2753 for value in ["team@acme.example", " https://acme.example/report ", ""] {
2754 super::canonical_contact(value).expect("a control-free line is a contact");
2755 }
2756 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2757 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2758 assert!(refusal.contains("security.contact"), "{refusal}");
2759 }
2760 assert_eq!(
2761 super::canonical_contact(" team@acme.example "),
2762 Ok("team@acme.example".to_owned()),
2763 "surrounding whitespace is trimmed"
2764 );
2765 for value in [
2766 "best-effort",
2767 "1 day",
2768 "2 days",
2769 "14 days",
2770 "1 business day",
2771 "14 business days",
2772 ] {
2773 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2774 }
2775 assert_eq!(
2776 super::canonical_response(""),
2777 Ok(super::RESPONSE_DEFAULT.to_owned()),
2778 "an empty answer reads as the compiled default"
2779 );
2780 for value in [
2781 "0 days",
2782 "1 days",
2783 "2 day",
2784 "+2 days",
2785 "02 days",
2786 "4294967296 days",
2787 "90d",
2788 "two days",
2789 "we answer quickly",
2790 "2 weeks",
2791 ] {
2792 let refusal =
2793 super::canonical_response(value).expect_err("an unstateable window refuses");
2794 assert!(refusal.contains("security.response"), "{value}: {refusal}");
2795 assert!(refusal.contains("business days"), "{value}: {refusal}");
2796 }
2797 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2798 .expect_err("the reader refuses it too")
2799 .to_string();
2800 assert!(refusal.contains("security.response"), "{refusal}");
2801 }
2802
2803 #[test]
2807 fn a_migration_key_that_is_no_identifier_refuses() {
2808 for id in ["20261009T140205Z", "20261009T140205Z-2"] {
2809 let config = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2810 .expect("an identifier parses");
2811 assert_eq!(config.migration.as_deref(), Some(id));
2812 }
2813 for id in [
2814 "../../elsewhere",
2815 "20261009T140205Z-1",
2816 "",
2817 "20261009T140205\u{e9}",
2818 "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
2819 ] {
2820 let refusal = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2821 .expect_err("a value that is no identifier refuses")
2822 .to_string();
2823 assert!(
2824 refusal.contains("no migration identifier"),
2825 "{id}: {refusal}"
2826 );
2827 }
2828 }
2829
2830 #[test]
2835 fn a_pruned_header_leaves_no_comment_behind() {
2836 let cases = [
2837 (
2838 "a middle table, comment above",
2839 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2840 ),
2841 (
2842 "a middle table, comment inline",
2843 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2844 ),
2845 (
2846 "the last table, comment above",
2847 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
2848 ),
2849 (
2850 "the last table, comment inline",
2851 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
2852 ),
2853 ];
2854 for (case, text) in cases {
2855 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2856 assert!(!next.contains("repo ="), "{case}: {next}");
2857 assert!(
2858 !next.contains("[project]"),
2859 "{case}: a table every one of whose keys dropped goes with them: {next}"
2860 );
2861 assert!(
2862 next.contains("# the operator's note"),
2863 "{case}: the comment the header carried survives: {next}"
2864 );
2865 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2866 }
2867 }
2868
2869 #[test]
2875 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2876 let text = concat!(
2877 "schema_version = 2\n\n[project]\n",
2878 "# the operator's note\n",
2879 "repo = \"acme/widget\" # P: project path on the forge\n\n",
2880 "[git]\ntrunk = \"main\"\n"
2881 );
2882 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2883 assert!(!next.contains("repo ="), "{next}");
2884 assert!(!next.contains("[project]"), "{next}");
2885 assert!(
2886 next.contains("# the operator's note"),
2887 "authored text survives: {next}"
2888 );
2889 assert!(
2890 !next.contains("# P:"),
2891 "the template's comment describes a key that is gone: {next}"
2892 );
2893 parse(&next).expect("the result parses");
2894
2895 let text = concat!(
2897 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2898 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2899 "# why this project names its own prefix\n",
2900 "line_prefix = \"stable/\"\n"
2901 );
2902 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2903 .expect("the key removes");
2904 assert!(!next.contains("line_prefix ="), "{next}");
2905 assert!(next.contains("[profile.release]"), "{next}");
2906 assert!(
2907 next.contains("# why this project names its own prefix"),
2908 "authored text survives: {next}"
2909 );
2910 }
2911}