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 pub scope: Option<String>,
236}
237
238pub const RESPONSE_DEFAULT: &str = "best-effort";
241
242pub const LOCAL_GITHUB_BYPASS: &str = "repository-admin";
248
249pub fn canonical_contact(raw: &str) -> Result<String, String> {
259 let trimmed = raw.trim();
260 if trimmed.chars().any(char::is_control) {
261 return Err(format!(
262 "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:?}"
263 ));
264 }
265 Ok(trimmed.to_owned())
266}
267
268pub fn canonical_scope(raw: &str) -> Result<String, String> {
280 let trimmed = raw.trim();
281 let refusal = |why: &str| {
282 format!(
283 "security.scope {why}; it is one paragraph naming what the project does not own, and empty renders nothing, found {trimmed:?}"
284 )
285 };
286 if trimmed.chars().any(|c| c.is_control() && c != '\n') {
287 return Err(refusal(
288 "carries a control character other than a line feed",
289 ));
290 }
291 if trimmed.lines().any(|line| line.trim().is_empty()) {
292 return Err(refusal("carries a blank line"));
293 }
294 if trimmed
295 .lines()
296 .any(|line| line.trim_start().starts_with('#'))
297 {
298 return Err(refusal("carries a line that opens a Markdown heading"));
299 }
300 if trimmed.lines().any(|line| {
304 let line = line.trim();
305 line.chars().all(|c| c == '=') || line.chars().all(|c| c == '-')
306 }) {
307 return Err(refusal(
308 "carries a line of = or - alone, which underlines a Markdown heading",
309 ));
310 }
311 if trimmed.contains("<!--") || trimmed.contains("-->") {
312 return Err(refusal("carries a comment delimiter, <!-- or -->"));
313 }
314 Ok(trimmed.to_owned())
315}
316
317pub fn canonical_response(raw: &str) -> Result<String, String> {
329 let trimmed = raw.trim();
330 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
331 return Ok(RESPONSE_DEFAULT.to_owned());
332 }
333 let refusal = || {
334 format!(
335 "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:?}"
336 )
337 };
338 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
339 let plural = match unit {
340 "day" | "business day" => false,
341 "days" | "business days" => true,
342 _ => return Err(refusal()),
343 };
344 let number: u32 = count.parse().map_err(|_| refusal())?;
345 if count != number.to_string() || (number > 1) != plural {
348 return Err(refusal());
349 }
350 Ok(trimmed.to_owned())
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
355#[serde(deny_unknown_fields, default)]
356pub struct Setup {
357 pub required_check: String,
360 pub required_workflow: String,
364 pub retired_branches: Vec<String>,
366 pub release_lines: bool,
368 pub excluded_steps: BTreeMap<String, String>,
373 pub bot: Bot,
375}
376
377impl Default for Setup {
378 fn default() -> Self {
379 Self {
380 required_check: String::new(),
381 required_workflow: String::new(),
382 retired_branches: vec!["main".into(), "develop".into()],
383 release_lines: false,
384 excluded_steps: BTreeMap::new(),
385 bot: Bot::default(),
386 }
387 }
388}
389
390#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
397#[serde(deny_unknown_fields, default)]
398pub struct Bot {
399 pub app_id: String,
401 #[serde(default, skip_serializing)]
406 pub installation_id: Option<i64>,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
411#[serde(deny_unknown_fields, default)]
412#[allow(
413 clippy::struct_excessive_bools,
414 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
415)]
416pub struct Protection {
417 pub trunk_ruleset: Option<String>,
421 pub safety_ruleset: Option<String>,
426 pub tag_ruleset: String,
428 pub lines_ruleset: String,
430 pub title_check: String,
432 pub tag_pattern: String,
434 pub bypass_actors: Vec<String>,
437 pub allowed_merge_methods: Vec<String>,
440 pub strict_required_status_checks: bool,
442 pub owned_trunk_rules: Vec<String>,
446 pub required_approving_review_count: i64,
448 pub dismiss_stale_reviews_on_push: bool,
450 pub require_code_owner_review: bool,
452 pub require_last_push_approval: bool,
454 pub github: Github,
456 pub gitlab: Gitlab,
458}
459
460impl Default for Protection {
461 fn default() -> Self {
462 Self {
463 trunk_ruleset: None,
464 safety_ruleset: None,
465 tag_ruleset: "release-tags".into(),
466 lines_ruleset: "release-lines".into(),
467 title_check: "pr-title".into(),
468 tag_pattern: "refs/tags/v*".into(),
469 bypass_actors: Vec::new(),
470 allowed_merge_methods: vec!["squash".into()],
471 strict_required_status_checks: true,
472 owned_trunk_rules: vec![
473 "deletion".into(),
474 "non_fast_forward".into(),
475 "pull_request".into(),
476 "required_status_checks".into(),
477 ],
478 required_approving_review_count: 0,
479 dismiss_stale_reviews_on_push: false,
480 require_code_owner_review: false,
481 require_last_push_approval: false,
482 github: Github::default(),
483 gitlab: Gitlab::default(),
484 }
485 }
486}
487
488impl Protection {
489 #[must_use]
492 pub fn trunk_ruleset(&self, trunk: &str) -> String {
493 self.trunk_ruleset
494 .clone()
495 .unwrap_or_else(|| format!("{trunk}-protection"))
496 }
497
498 #[must_use]
505 pub fn safety_ruleset(&self, trunk: &str) -> String {
506 self.safety_ruleset
507 .clone()
508 .unwrap_or_else(|| format!("{trunk}-safety"))
509 }
510}
511
512pub const SAFETY_RULES: [&str; 3] = ["deletion", "non_fast_forward", LINEAR_HISTORY_RULE];
522
523pub const COMMIT_GATE_CHECK: &str = "pr-commits";
527
528pub const LINEAR_HISTORY_RULE: &str = "required_linear_history";
532
533pub const REQUEST_RULES: [&str; 2] = ["pull_request", "required_status_checks"];
536
537#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
539#[serde(deny_unknown_fields, default)]
540pub struct Github {
541 pub squash_title_source: String,
544 pub squash_body_source: String,
546}
547
548impl Default for Github {
549 fn default() -> Self {
550 Self {
551 squash_title_source: "PR_TITLE".into(),
552 squash_body_source: "PR_BODY".into(),
553 }
554 }
555}
556
557#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
559#[serde(deny_unknown_fields, default)]
560pub struct Gitlab {
561 pub merge_method: String,
563 pub squash_option: String,
567 pub squash_commit_template: String,
569 pub push_access_level: i64,
574 pub merge_access_level: i64,
576}
577
578impl Default for Gitlab {
579 fn default() -> Self {
580 Self {
581 merge_method: "ff".into(),
582 squash_option: "always".into(),
583 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
584 .trim_end_matches('\n')
585 .to_owned(),
586 push_access_level: 0,
587 merge_access_level: 40,
588 }
589 }
590}
591
592pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
597 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
598 Ok(text) => text,
599 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
600 Err(error) => return Err(error.into()),
601 };
602 parse(&text).map(Some)
603}
604
605fn current_text(text: &str) -> Result<String, RkError> {
608 let raw: toml::Value =
609 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
610 match raw.get("schema_version").and_then(toml::Value::as_integer) {
611 Some(1) => migrate::to_schema_2(text),
612 _ => Ok(text.to_owned()),
613 }
614}
615
616fn release_vocabulary(raw: &toml::Value) -> Result<(), RkError> {
622 let release = raw
623 .get("profile")
624 .and_then(|profile| profile.get("release"));
625 if let Some(mode) = release.and_then(|release| release.get("mode")) {
626 let named = mode.as_str().ok_or_else(|| {
627 invalid("profile.release.mode must be a string: automatic, external, or none")
628 })?;
629 crate::profile::ReleaseMode::parse(named)
630 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
631 }
632 match release.and_then(|release| release.get("registry")) {
633 Some(registry) if !matches!(registry.as_str(), Some("crates-io" | "none")) => {
634 Err(invalid(format!(
635 "profile.release.registry is {registry}; it takes crates-io or none, where the crate publishes, for an automatic rust release alone"
636 )))
637 }
638 _ => Ok(()),
639 }
640}
641
642fn registry_fit(release: &Release) -> Result<(), RkError> {
645 if release.registry.is_some()
646 && (release.driver.is_some()
647 || matches!(
648 release.mode,
649 Some(ReleaseMode::External | ReleaseMode::None)
650 ))
651 {
652 crate::profile::catalog::registry_answer_fits(
653 release.mode.unwrap_or(ReleaseMode::Automatic),
654 release.driver.as_deref(),
655 )
656 .map_err(invalid)?;
657 }
658 Ok(())
659}
660
661fn parse(text: &str) -> Result<Config, RkError> {
662 let text = current_text(text)?;
663 let raw: toml::Value =
664 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
665 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
666 return Err(invalid(format!(
667 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
668 )));
669 }
670 release_vocabulary(&raw)?;
671 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
672 let mut message = error.to_string();
673 if let Some(rest) = error.message().strip_prefix("unknown field `") {
674 let names: Vec<_> = rest.split('`').collect();
675 if let Some(unknown) = names.first()
676 && let Some(nearest) = names
677 .iter()
678 .skip(2)
679 .step_by(2)
680 .min_by_key(|name| distance(unknown, name))
681 {
682 let _ = write!(message, "; nearest known key: {nearest}");
683 }
684 }
685 invalid(message)
686 })?;
687 if let Some(technologies) = &config.profile.technologies {
688 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
689 }
690 if let Some(forge) = &config.profile.forge
691 && !forge.is_empty()
692 {
693 crate::profile::canonical_category(forge)
694 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
695 }
696 if let Some(driver) = &config.profile.release.driver {
697 crate::profile::canonical_category(driver)
698 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
699 }
700 if matches!(
701 config.profile.release.mode,
702 Some(ReleaseMode::External | ReleaseMode::None)
703 ) {
704 let mode = config
705 .profile
706 .release
707 .mode
708 .map_or("none", ReleaseMode::as_str);
709 for (key, present) in [
710 (
711 "profile.release.driver",
712 config.profile.release.driver.is_some(),
713 ),
714 (
715 "profile.release.style",
716 config.profile.release.style.is_some(),
717 ),
718 (
719 "profile.release.line_prefix",
720 config.profile.release.line_prefix.is_some(),
721 ),
722 ] {
723 if present {
724 return Err(invalid(format!(
725 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
726 )));
727 }
728 }
729 }
730 registry_fit(&config.profile.release)?;
731 if let Some(contact) = &config.security.contact {
732 canonical_contact(contact).map_err(invalid)?;
733 }
734 if let Some(response) = &config.security.response {
735 canonical_response(response).map_err(invalid)?;
736 }
737 if let Some(scope) = &config.security.scope {
738 canonical_scope(scope).map_err(invalid)?;
739 }
740 exclusions(&config.setup.excluded_steps)?;
741 if let Some(acquisition) = &config.acquisition
742 && !is_release_version(&acquisition.minimum)
743 {
744 return Err(invalid(format!(
745 "acquisition.minimum is {}, which is no release version; write one such as {}",
746 acquisition.minimum,
747 env!("CARGO_PKG_VERSION")
748 )));
749 }
750 if let Some(answer) = &config.dev_environment {
751 answer.check().map_err(invalid)?;
752 }
753 migration_key(&config)?;
754 floors::check(&config)?;
755 Ok(config)
756}
757
758fn migration_key(config: &Config) -> Result<(), RkError> {
761 match &config.migration {
762 Some(id) if !crate::migrate::is_id(id) => Err(invalid(format!(
763 "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)"
764 ))),
765 _ => Ok(()),
766 }
767}
768
769fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
776 for (name, reason) in excluded {
777 if crate::setup::steps::spec(name).is_none() {
778 let nearest = crate::setup::steps::STEPS
779 .iter()
780 .min_by_key(|step| distance(name, step.name))
781 .map_or("", |step| step.name);
782 return Err(invalid(format!(
783 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
784 )));
785 }
786 if reason.trim().is_empty() {
787 return Err(invalid(format!(
788 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
789 )));
790 }
791 }
792 Ok(())
793}
794
795pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
796 RkError::refusal(
797 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
798 .action(format!("edit {CONFIG_PATH} and retry"))
799 .target_state("nothing was written"),
800 )
801}
802
803fn distance(left: &str, right: &str) -> usize {
804 let mut row: Vec<_> = (0..=right.chars().count()).collect();
805 for (i, a) in left.chars().enumerate() {
806 let mut previous = row[0];
807 row[0] = i + 1;
808 for (j, b) in right.chars().enumerate() {
809 let old = row[j + 1];
810 row[j + 1] = (previous + usize::from(a != b))
811 .min(row[j] + 1)
812 .min(old + 1);
813 previous = old;
814 }
815 }
816 row.last().copied().unwrap_or(0)
817}
818
819pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
825 let bytes = render(config)?;
826 parse(&String::from_utf8_lossy(&bytes))?;
827 write_checked(target, &bytes)
828}
829
830fn write_checked(target: &Path, bytes: &[u8]) -> Result<(), RkError> {
833 check_writable(target)?;
834 crate::atomic::write(&target.join(CONFIG_PATH), bytes)?;
835 Ok(())
836}
837
838fn array(values: &[String]) -> toml_edit::Value {
839 toml_edit::Value::Array(values.iter().collect())
840}
841
842fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
847 let mut table = toml_edit::InlineTable::new();
848 for (key, value) in values {
849 table.insert(key, value.clone().into());
850 }
851 toml_edit::Value::InlineTable(table)
852}
853
854#[allow(
859 clippy::too_many_lines,
860 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
861)]
862fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
863 let trunk = config
867 .git
868 .trunk
869 .clone()
870 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
871 let technologies = config
872 .profile
873 .technologies
874 .clone()
875 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
876 let forge = config
877 .profile
878 .forge
879 .clone()
880 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
881 let mode = config
882 .profile
883 .release
884 .mode
885 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
886 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
887 (
888 "RK_CONFIG_SCHEMA_VERSION",
889 Some(config.schema_version.into()),
890 ),
891 (
892 "RK_CONFIG_PROJECT_REPO",
893 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
894 ),
895 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
896 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
897 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
898 (
899 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
900 config
901 .profile
902 .release
903 .driver
904 .clone()
905 .map(toml_edit::Value::from),
906 ),
907 (
908 "RK_CONFIG_PROFILE_RELEASE_STYLE",
909 config
910 .profile
911 .release
912 .style
913 .map(|style| style.as_str().into()),
914 ),
915 (
916 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
917 config
918 .profile
919 .release
920 .line_prefix
921 .clone()
922 .map(toml_edit::Value::from),
923 ),
924 (
925 "RK_CONFIG_PROFILE_RELEASE_REGISTRY",
926 config
927 .profile
928 .release
929 .registry
930 .map(|answer| answer.as_str().into()),
931 ),
932 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
933 (
934 "RK_CONFIG_GIT_CHECKOUT_MODE",
935 Some(
936 config
937 .git
938 .checkout_mode
939 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
940 .as_str()
941 .into(),
942 ),
943 ),
944 (
945 "RK_CONFIG_GIT_INTEGRATION",
946 Some(
947 config
948 .git
949 .integration
950 .ok_or_else(|| invalid("git.integration is unresolved"))?
951 .as_str()
952 .into(),
953 ),
954 ),
955 (
956 "RK_CONFIG_GIT_MERGE_FORM",
957 Some(
958 config
959 .git
960 .merge_form
961 .ok_or_else(|| invalid("git.merge_form is unresolved"))?
962 .as_str()
963 .into(),
964 ),
965 ),
966 (
967 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
968 Some(
969 config
970 .capabilities
971 .nix_packaging
972 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
973 .into(),
974 ),
975 ),
976 (
977 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
978 Some(
979 config
980 .capabilities
981 .reporting_policy
982 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
983 .into(),
984 ),
985 ),
986 (
987 "RK_CONFIG_CAPABILITIES_SCORECARD",
988 Some(
989 config
990 .capabilities
991 .scorecard
992 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
993 .into(),
994 ),
995 ),
996 (
997 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
998 Some(
999 config
1000 .capabilities
1001 .code_scanning
1002 .clone()
1003 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
1004 .into(),
1005 ),
1006 ),
1007 (
1008 "RK_CONFIG_CAPABILITIES_WORKFLOW_GUARDS",
1009 Some(
1010 config
1011 .capabilities
1012 .workflow_guards
1013 .ok_or_else(|| invalid("capabilities.workflow_guards is unresolved"))?
1014 .into(),
1015 ),
1016 ),
1017 (
1018 "RK_CONFIG_SECURITY_ADVISORIES",
1019 Some(config.security.advisories.clone().into()),
1020 ),
1021 (
1022 "RK_CONFIG_SECURITY_CONTACT",
1023 Some(config.security.contact.clone().unwrap_or_default().into()),
1024 ),
1025 (
1026 "RK_CONFIG_SECURITY_RESPONSE",
1027 Some(
1028 config
1029 .security
1030 .response
1031 .clone()
1032 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
1033 .into(),
1034 ),
1035 ),
1036 (
1037 "RK_CONFIG_SECURITY_SCOPE",
1038 Some(config.security.scope.clone().unwrap_or_default().into()),
1039 ),
1040 (
1041 "RK_CONFIG_SETUP_REQUIRED_CHECK",
1042 Some(config.setup.required_check.clone().into()),
1043 ),
1044 (
1045 "RK_CONFIG_SETUP_REQUIRED_WORKFLOW",
1046 Some(config.setup.required_workflow.clone().into()),
1047 ),
1048 (
1049 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
1050 Some(array(&config.setup.retired_branches)),
1051 ),
1052 (
1053 "RK_CONFIG_SETUP_RELEASE_LINES",
1054 Some(config.setup.release_lines.into()),
1055 ),
1056 (
1057 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
1058 Some(inline(&config.setup.excluded_steps)),
1059 ),
1060 (
1061 "RK_CONFIG_SETUP_BOT_APP_ID",
1062 Some(config.setup.bot.app_id.clone().into()),
1063 ),
1064 (
1065 "RK_CONFIG_ACQUISITION_SOURCE",
1066 config
1067 .acquisition
1068 .as_ref()
1069 .map(|answer| answer.source.as_str().into()),
1070 ),
1071 (
1072 "RK_CONFIG_ACQUISITION_MINIMUM",
1073 config
1074 .acquisition
1075 .as_ref()
1076 .map(|answer| answer.minimum.clone().into()),
1077 ),
1078 ];
1079 fields.extend(
1080 protection_fields(&config.protection, trunk.as_str())
1081 .into_iter()
1082 .map(|(token, value)| (token, Some(value))),
1083 );
1084 if mode == crate::profile::ReleaseMode::None {
1090 let reporting = config.capabilities.reporting_policy.unwrap_or(false);
1091 for (token, value) in &mut fields {
1092 if RELEASE_ONLY_TOKENS.contains(token)
1093 || (!reporting && SECURITY_TOKENS.contains(token))
1094 {
1095 *value = None;
1096 }
1097 }
1098 }
1099 Ok(fields)
1100}
1101
1102const RELEASE_ONLY_TOKENS: [&str; 4] = [
1108 "RK_CONFIG_PROTECTION_TAG_RULESET",
1109 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1110 "RK_CONFIG_PROTECTION_LINES_RULESET",
1111 "RK_CONFIG_SETUP_BOT_APP_ID",
1112];
1113
1114const SECURITY_TOKENS: [&str; 4] = [
1117 "RK_CONFIG_SECURITY_ADVISORIES",
1118 "RK_CONFIG_SECURITY_CONTACT",
1119 "RK_CONFIG_SECURITY_RESPONSE",
1120 "RK_CONFIG_SECURITY_SCOPE",
1121];
1122
1123fn render(config: &Config) -> Result<Vec<u8>, RkError> {
1124 let fields = fields(config)?;
1125 let template = crate::embedded::BLOCKS
1126 .get_file("target-config.toml.in")
1127 .and_then(include_dir::File::contents_utf8)
1128 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
1129 let lines: Vec<&str> = template.split_inclusive('\n').collect();
1135 let dropped: Vec<bool> = lines
1136 .iter()
1137 .map(|line| {
1138 matches!(
1139 fields.iter().find(|(token, _)| line.contains(token)),
1140 Some((_, None))
1141 )
1142 })
1143 .collect();
1144 let mut bytes = Vec::new();
1145 let mut at = 0;
1146 while at < lines.len() {
1147 let line = lines[at];
1148 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
1149 let mut end = at + 1;
1150 let mut keeps = false;
1151 while end < lines.len()
1152 && !(lines[end].trim_start().starts_with('[')
1153 && lines[end].trim_end().ends_with(']'))
1154 {
1155 keeps |=
1156 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
1157 end += 1;
1158 }
1159 if !keeps {
1160 while bytes.last() == Some(&b'\n')
1162 && bytes.len() >= 2
1163 && bytes[bytes.len() - 2] == b'\n'
1164 {
1165 bytes.pop();
1166 }
1167 at = end;
1168 continue;
1169 }
1170 }
1171 if !dropped[at] {
1172 match fields.iter().find(|(token, _)| line.contains(token)) {
1173 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
1174 line.as_bytes(),
1175 token.as_bytes(),
1176 value.to_string().as_bytes(),
1177 )),
1178 Some((_, None)) => {}
1179 None => bytes.extend_from_slice(line.as_bytes()),
1180 }
1181 }
1182 at += 1;
1183 }
1184 Ok(bytes)
1185}
1186
1187fn protection_fields(
1188 protection: &Protection,
1189 trunk: &str,
1190) -> Vec<(&'static str, toml_edit::Value)> {
1191 vec![
1192 (
1193 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
1194 protection.trunk_ruleset(trunk).into(),
1195 ),
1196 (
1197 "RK_CONFIG_PROTECTION_SAFETY_RULESET",
1198 protection.safety_ruleset(trunk).into(),
1199 ),
1200 (
1201 "RK_CONFIG_PROTECTION_TAG_RULESET",
1202 protection.tag_ruleset.clone().into(),
1203 ),
1204 (
1205 "RK_CONFIG_PROTECTION_LINES_RULESET",
1206 protection.lines_ruleset.clone().into(),
1207 ),
1208 (
1209 "RK_CONFIG_PROTECTION_TITLE_CHECK",
1210 protection.title_check.clone().into(),
1211 ),
1212 (
1213 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1214 protection.tag_pattern.clone().into(),
1215 ),
1216 (
1217 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
1218 array(&protection.bypass_actors),
1219 ),
1220 (
1221 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
1222 array(&protection.allowed_merge_methods),
1223 ),
1224 (
1225 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
1226 protection.strict_required_status_checks.into(),
1227 ),
1228 (
1229 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
1230 array(&protection.owned_trunk_rules),
1231 ),
1232 (
1233 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
1234 protection.required_approving_review_count.into(),
1235 ),
1236 (
1237 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
1238 protection.dismiss_stale_reviews_on_push.into(),
1239 ),
1240 (
1241 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
1242 protection.require_code_owner_review.into(),
1243 ),
1244 (
1245 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
1246 protection.require_last_push_approval.into(),
1247 ),
1248 (
1249 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
1250 protection.github.squash_title_source.clone().into(),
1251 ),
1252 (
1253 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
1254 protection.github.squash_body_source.clone().into(),
1255 ),
1256 (
1257 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
1258 protection.gitlab.merge_method.clone().into(),
1259 ),
1260 (
1261 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
1262 protection.gitlab.squash_option.clone().into(),
1263 ),
1264 (
1265 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
1266 protection.gitlab.squash_commit_template.clone().into(),
1267 ),
1268 (
1269 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
1270 protection.gitlab.push_access_level.into(),
1271 ),
1272 (
1273 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
1274 protection.gitlab.merge_access_level.into(),
1275 ),
1276 ]
1277}
1278
1279const DERIVED_POLICY_KEYS: [&str; 5] = [
1290 "protection.bypass_actors",
1291 "protection.owned_trunk_rules",
1292 "protection.gitlab.push_access_level",
1293 "protection.allowed_merge_methods",
1294 "protection.gitlab.squash_option",
1295];
1296
1297const PARAMETER_KEYS: [&str; 28] = [
1299 "project.repo",
1300 "profile.technologies",
1301 "profile.forge",
1302 "profile.release.mode",
1303 "profile.release.driver",
1304 "profile.release.style",
1305 "profile.release.line_prefix",
1306 "profile.release.registry",
1307 "git.trunk",
1308 "git.checkout_mode",
1309 "git.integration",
1310 "git.merge_form",
1311 "protection.allowed_merge_methods",
1314 "protection.gitlab.squash_option",
1315 "protection.bypass_actors",
1319 "protection.owned_trunk_rules",
1320 "protection.gitlab.push_access_level",
1321 "capabilities.nix_packaging",
1322 "capabilities.reporting_policy",
1323 "capabilities.scorecard",
1324 "capabilities.code_scanning",
1325 "capabilities.workflow_guards",
1326 "security.contact",
1327 "security.response",
1328 "security.scope",
1329 "setup.required_check",
1332 "setup.required_workflow",
1333 "schema_version",
1334];
1335
1336pub fn with_acquisition(target: &Path, answer: &Acquisition) -> Result<(String, bool), RkError> {
1348 let path = target.join(CONFIG_PATH);
1349 let text = match std::fs::read_to_string(&path) {
1350 Ok(text) => text,
1351 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1352 Err(error) => return Err(error.into()),
1353 };
1354 if text.is_empty() {
1355 let template = crate::embedded::BLOCKS
1356 .get_file("acquisition-config.toml.in")
1357 .and_then(include_dir::File::contents_utf8)
1358 .ok_or_else(|| invalid("the binary lacks its acquisition template"))?;
1359 let mut fresh = template.as_bytes().to_vec();
1360 for (token, value) in [
1361 (
1362 "RK_CONFIG_SCHEMA_VERSION",
1363 toml_edit::Value::from(SCHEMA_VERSION),
1364 ),
1365 (
1366 "RK_CONFIG_ACQUISITION_SOURCE",
1367 toml_edit::Value::from(answer.source.as_str()),
1368 ),
1369 (
1370 "RK_CONFIG_ACQUISITION_MINIMUM",
1371 toml_edit::Value::from(answer.minimum.clone()),
1372 ),
1373 ] {
1374 fresh =
1375 crate::landing::substitute(&fresh, token.as_bytes(), value.to_string().as_bytes());
1376 }
1377 let fresh = String::from_utf8(fresh).map_err(|error| invalid(error.to_string()))?;
1378 parse(&fresh)?;
1379 return Ok((fresh, true));
1380 }
1381 let held = parse(&text)?;
1382 if held.acquisition.as_ref() == Some(answer) {
1383 return Ok((text, false));
1384 }
1385 let text = current_text(&text)?;
1386 let mut document = text
1387 .parse::<toml_edit::DocumentMut>()
1388 .map_err(|error| invalid(error.to_string()))?;
1389 if !document.contains_table("acquisition") {
1390 let mut table = toml_edit::Table::new();
1391 table.decor_mut().set_prefix("\n");
1392 document.insert("acquisition", toml_edit::Item::Table(table));
1393 }
1394 let table = document
1395 .get_mut("acquisition")
1396 .and_then(toml_edit::Item::as_table_mut)
1397 .ok_or_else(|| invalid("acquisition must be a table"))?;
1398 for (key, value, note) in [
1399 (
1400 "source",
1401 answer.source.as_str().to_owned(),
1402 " P: host where every host installs rk itself; absent where a manager file pins it",
1403 ),
1404 (
1405 "minimum",
1406 answer.minimum.clone(),
1407 " P: the lowest rk release a host may run against this project",
1408 ),
1409 ] {
1410 let kept = table
1411 .get(key)
1412 .and_then(toml_edit::Item::as_value)
1413 .map(|held| held.decor().clone());
1414 let mut value = toml_edit::Value::from(value);
1415 match kept {
1416 Some(decor) => *value.decor_mut() = decor,
1417 None => value.decor_mut().set_suffix(format!(" #{note}")),
1418 }
1419 table.insert(key, toml_edit::Item::Value(value));
1420 }
1421 let next = document.to_string();
1422 parse(&next)?;
1423 Ok((next, true))
1424}
1425
1426impl Config {
1427 #[must_use]
1430 pub fn states_only_a_migration(&self) -> bool {
1431 self.migration.is_some()
1432 && Self {
1433 migration: None,
1434 ..self.clone()
1435 } == Self::default()
1436 }
1437}
1438
1439pub fn with_migration(text: &str, id: Option<&str>) -> Result<String, RkError> {
1446 let mut document = text
1447 .parse::<toml_edit::DocumentMut>()
1448 .map_err(|error| invalid(error.to_string()))?;
1449 let root = document.as_table_mut();
1450 match id {
1451 Some(id) => {
1452 root.insert("migration", toml_edit::value(id));
1453 root.sort_values_by(|a, _, b, _| {
1455 let rank = |key: &toml_edit::Key| match key.get() {
1456 "schema_version" => 0,
1457 "migration" => 1,
1458 _ => 2,
1459 };
1460 rank(a).cmp(&rank(b))
1461 });
1462 }
1463 None => {
1464 root.remove("migration");
1465 }
1466 }
1467 let next = document.to_string();
1468 parse(&next)?;
1469 Ok(next)
1470}
1471
1472pub fn check_writable(target: &Path) -> Result<(), RkError> {
1482 let file = target.join(CONFIG_PATH);
1483 for (path, directory) in [(file.parent(), true), (Some(file.as_path()), false)] {
1484 let Some(path) = path else { continue };
1485 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1486 continue;
1487 };
1488 let kind = metadata.file_type();
1489 let fits = if directory {
1490 kind.is_dir()
1491 } else {
1492 kind.is_file()
1493 };
1494 if !fits {
1495 return Err(RkError::refusal(
1496 Diagnostic::new(
1497 Reason::StateDrift,
1498 format!(
1499 "{} is a symlink or not a regular {}, so a configuration write would not stay in the target",
1500 path.display(),
1501 if directory { "directory" } else { "file" }
1502 ),
1503 )
1504 .expected(format!("{CONFIG_PATH} as a regular file in a regular directory"))
1505 .action("replace the symlink with the directory or file it stands for")
1506 .target_state("unchanged"),
1507 ));
1508 }
1509 }
1510 Ok(())
1511}
1512
1513pub fn set_migration(target: &Path, id: Option<&str>) -> Result<(), RkError> {
1521 check_writable(target)?;
1522 let path = target.join(CONFIG_PATH);
1523 let text = match std::fs::read_to_string(&path) {
1524 Ok(text) => text,
1525 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1526 if id.is_none() {
1527 return Ok(());
1528 }
1529 format!("schema_version = {SCHEMA_VERSION}\n")
1530 }
1531 Err(error) => return Err(error.into()),
1532 };
1533 let next = with_migration(¤t_text(&text)?, id)?;
1534 write_checked(target, next.as_bytes())
1535}
1536
1537pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
1543 let path = target.join(CONFIG_PATH);
1544 let text = std::fs::read_to_string(&path)?;
1545 let next = rewrite_text(&text, key, Some(value))?;
1546 write_checked(target, next.as_bytes())
1547}
1548
1549fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
1552 rewrite_all(text, vec![(key, value)])
1553}
1554
1555fn rewrite_all(
1563 text: &str,
1564 values: Vec<(&str, Option<toml_edit::Value>)>,
1565) -> Result<String, RkError> {
1566 for (key, _) in &values {
1567 if !PARAMETER_KEYS.contains(key) {
1568 return Err(invalid(format!("{key} is not a landing parameter")));
1569 }
1570 }
1571 parse(text)?;
1572 let text = current_text(text)?;
1573 let mut document = text
1574 .parse::<toml_edit::DocumentMut>()
1575 .map_err(|error| invalid(error.to_string()))?;
1576 let mut changed = false;
1577 let mut emptied: Vec<&str> = Vec::new();
1580 for (key, value) in values {
1581 let removing = value.is_none();
1582 let applied = apply_key(&mut document, key, value)?;
1583 changed |= applied;
1584 if applied
1585 && removing
1586 && let Some(parent) = key.split('.').next()
1587 {
1588 emptied.push(parent);
1589 }
1590 }
1591 changed |= prune_empty_tables(&mut document, &emptied);
1592 changed |= migrate::refresh_comments(&mut document);
1599 if !changed {
1600 return Ok(text);
1601 }
1602 let next = document.to_string();
1603 parse(&next)?;
1604 Ok(next)
1605}
1606
1607pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1626 let order: Vec<String> = document
1627 .as_table()
1628 .iter()
1629 .map(|(key, _)| key.to_owned())
1630 .collect();
1631 let mut changed = false;
1632 for (index, name) in order.iter().enumerate() {
1633 if !names.contains(&name.as_str())
1634 || !document
1635 .get(name)
1636 .and_then(toml_edit::Item::as_table)
1637 .is_some_and(toml_edit::Table::is_empty)
1638 {
1639 continue;
1640 }
1641 let carried = document
1642 .get(name)
1643 .and_then(toml_edit::Item::as_table)
1644 .and_then(|table| carried_comment(table.decor()));
1645 document.remove(name);
1646 changed = true;
1647 let Some(carried) = carried else { continue };
1648 let next = order
1652 .iter()
1653 .skip(index + 1)
1654 .find(|name| {
1655 document
1656 .get(name)
1657 .and_then(toml_edit::Item::as_table)
1658 .is_some_and(|table| !table.is_implicit())
1659 })
1660 .cloned();
1661 if let Some(next) = next
1662 && let Some(table) = document
1663 .get_mut(&next)
1664 .and_then(toml_edit::Item::as_table_mut)
1665 {
1666 let existing = table
1667 .decor()
1668 .prefix()
1669 .and_then(toml_edit::RawString::as_str)
1670 .unwrap_or_default()
1671 .trim_start_matches('\n')
1672 .to_owned();
1673 table
1674 .decor_mut()
1675 .set_prefix(format!("\n{carried}{existing}"));
1676 } else {
1677 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1678 if !trailing.is_empty() && !trailing.ends_with('\n') {
1679 trailing.push('\n');
1680 }
1681 trailing.push_str(&carried);
1682 document.set_trailing(trailing);
1683 }
1684 }
1685 changed
1686}
1687
1688fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1695 let mut lines = String::new();
1696 for raw in [decor.prefix(), decor.suffix()] {
1697 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1698 continue;
1699 };
1700 for line in text
1701 .lines()
1702 .map(str::trim)
1703 .filter(|line| line.starts_with('#'))
1704 .filter(|line| !is_template_comment(line))
1705 {
1706 lines.push_str(line);
1707 lines.push('\n');
1708 }
1709 }
1710 (!lines.is_empty()).then_some(lines)
1711}
1712
1713fn is_template_comment(line: &str) -> bool {
1722 let rest = line.trim_start_matches('#').trim_start();
1723 ["P:", "N:", "F:"]
1724 .iter()
1725 .any(|marker| rest.starts_with(marker))
1726}
1727
1728pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1736 let first = document
1737 .as_table()
1738 .iter()
1739 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1740 .map(|(name, _)| name.to_owned());
1741 if let Some(first) = first
1742 && let Some(table) = document
1743 .get_mut(&first)
1744 .and_then(toml_edit::Item::as_table_mut)
1745 {
1746 let existing = table
1747 .decor()
1748 .prefix()
1749 .and_then(toml_edit::RawString::as_str)
1750 .unwrap_or_default()
1751 .trim_start_matches('\n')
1752 .to_owned();
1753 table
1754 .decor_mut()
1755 .set_prefix(format!("\n{carried}{existing}"));
1756 return;
1757 }
1758 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1759 if !trailing.is_empty() && !trailing.ends_with('\n') {
1760 trailing.push('\n');
1761 }
1762 trailing.push_str(carried);
1763 document.set_trailing(trailing);
1764}
1765
1766pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1772 let (name, _) = table.get_key_value(key)?;
1773 carried_comment(name.leaf_decor())
1774}
1775
1776pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1778 let Some(mut name) = table.key_mut(key) else {
1779 return;
1780 };
1781 let decor = name.leaf_decor_mut();
1782 let existing = decor
1783 .prefix()
1784 .and_then(toml_edit::RawString::as_str)
1785 .unwrap_or_default()
1786 .trim_start_matches('\n')
1787 .to_owned();
1788 decor.set_prefix(format!("\n{carried}{existing}"));
1789}
1790
1791#[must_use]
1793pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1794 known
1795 .iter()
1796 .min_by_key(|name| distance(unknown, name))
1797 .copied()
1798}
1799
1800pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1803 let carried = carried_comment(table.decor())?;
1804 table.decor_mut().set_prefix("\n");
1805 Some(carried)
1806}
1807
1808pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1819 let carried = table.get_key_value(key).and_then(|(name, item)| {
1820 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1821 if let Some(value) = item.as_value()
1822 && let Some(more) = carried_comment(value.decor())
1823 {
1824 lines.push_str(&more);
1825 }
1826 (!lines.is_empty()).then_some(lines)
1827 });
1828 let removed = table.remove(key).is_some();
1829 if let Some(carried) = carried {
1830 let existing = table
1831 .decor()
1832 .prefix()
1833 .and_then(toml_edit::RawString::as_str)
1834 .unwrap_or_default()
1835 .trim_start_matches('\n')
1836 .to_owned();
1837 table
1838 .decor_mut()
1839 .set_prefix(format!("\n{carried}{existing}"));
1840 }
1841 removed
1842}
1843
1844fn apply_key(
1847 document: &mut toml_edit::DocumentMut,
1848 key: &str,
1849 value: Option<toml_edit::Value>,
1850) -> Result<bool, RkError> {
1851 let segments: Vec<&str> = key.split('.').collect();
1852 let Some((last, parents)) = segments.split_last() else {
1855 return Err(invalid(format!("{key} names no key")));
1856 };
1857 let Some(mut value) = value else {
1858 let mut item = document.as_item_mut();
1859 for segment in parents {
1860 if item.get(segment).is_none() {
1861 return Ok(false);
1862 }
1863 item = &mut item[segment];
1864 }
1865 let removed = item
1866 .as_table_mut()
1867 .is_some_and(|table| take_comments(table, last));
1868 return Ok(removed);
1869 };
1870 let mut item = document.as_item_mut();
1871 for segment in parents {
1872 if item.get(segment).is_none() {
1873 let mut table = toml_edit::Table::new();
1874 table.set_implicit(true);
1875 item[segment] = toml_edit::Item::Table(table);
1876 }
1877 item = &mut item[segment];
1878 }
1879 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1880 if same_value(old, &value) {
1881 return Ok(false);
1882 }
1883 *value.decor_mut() = old.decor().clone();
1884 } else if let Some(comment) = migrate::template_comment(&segments) {
1885 value.decor_mut().set_suffix(comment);
1891 }
1892 item[last] = toml_edit::Item::Value(value);
1893 Ok(true)
1894}
1895
1896fn same_value(old: &toml_edit::Value, new: &toml_edit::Value) -> bool {
1900 let parsed = |value: &toml_edit::Value| {
1901 format!("v = {}", value.to_string().trim()).parse::<toml::Table>()
1902 };
1903 matches!((parsed(old), parsed(new)), (Ok(old), Ok(new)) if old == new)
1904}
1905
1906#[derive(Debug, Clone, serde::Serialize)]
1908pub struct Plan {
1909 pub action: &'static str,
1911 pub changes: Vec<String>,
1913 pub content: String,
1915}
1916
1917impl Plan {
1918 pub fn new(
1923 target: &Path,
1924 params: &crate::landing::Params,
1925 existing: Option<&Config>,
1926 record: Option<&crate::landing::manifest::Manifest>,
1927 ) -> Result<Self, RkError> {
1928 let text = existing
1929 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1930 .transpose()?;
1931 Self::compose(text.as_deref(), params, existing, record)
1932 }
1933
1934 pub fn compose(
1941 text: Option<&str>,
1942 params: &crate::landing::Params,
1943 existing: Option<&Config>,
1944 record: Option<&crate::landing::manifest::Manifest>,
1945 ) -> Result<Self, RkError> {
1946 let mut resolved = existing.cloned().unwrap_or_default();
1947 resolved.schema_version = SCHEMA_VERSION;
1948 params.repo().clone_into(&mut resolved.project.repo);
1949 resolved.profile = Profile {
1950 technologies: Some(params.technologies().to_vec()),
1951 forge: Some(params.forge().unwrap_or_default().to_owned()),
1952 release: Release {
1953 mode: Some(params.release_mode()),
1954 driver: params.driver().map(str::to_owned),
1955 style: params.style(),
1956 line_prefix: params.profile().release.line_prefix.clone(),
1957 registry: params.profile().release.registry,
1958 },
1959 };
1960 resolved.git = Git {
1961 trunk: Some(params.trunk().to_owned()),
1962 checkout_mode: Some(params.checkout_mode()),
1963 integration: Some(params.integration()),
1964 merge_form: Some(params.merge_form()),
1965 };
1966 resolved.protection = with_merge_form(
1967 &protection_for(
1968 &resolved.protection,
1969 params.integration(),
1970 existing.is_none(),
1971 ),
1972 params.merge_form(),
1973 );
1974 resolved.capabilities = Capabilities {
1975 nix_packaging: Some(params.nix_packaging()),
1976 reporting_policy: Some(params.reporting_policy()),
1977 scorecard: Some(params.scorecard()),
1978 code_scanning: Some(
1979 params
1980 .code_scanning()
1981 .map_or("off", crate::landing::Provider::as_str)
1982 .to_owned(),
1983 ),
1984 workflow_guards: Some(params.workflow_guards()),
1985 };
1986 if params.release_mode() != crate::profile::ReleaseMode::None || params.reporting_policy() {
1990 resolved.security.contact = Some(params.security_contact().to_owned());
1991 resolved.security.response = Some(params.security_response().to_owned());
1992 resolved.security.scope = Some(params.security_scope().to_owned());
1993 }
1994 params
1995 .required_check()
1996 .clone_into(&mut resolved.setup.required_check);
1997 params
1998 .required_workflow()
1999 .clone_into(&mut resolved.setup.required_workflow);
2000 let bare = existing.is_some_and(Config::states_only_a_migration);
2005 let content = if let Some(text) = text.filter(|_| existing.is_some() && !bare) {
2006 rewrite_all(text, parameter_values(&resolved))?
2007 } else {
2008 let rendered =
2009 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?;
2010 with_migration(&rendered, resolved.migration.as_deref())?
2011 };
2012 parse(&content)?;
2013 Ok(Self {
2014 action: if existing.is_some() {
2015 "updated"
2016 } else {
2017 "added"
2018 },
2019 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
2020 content,
2021 })
2022 }
2023
2024 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
2030 write_checked(target, self.content.as_bytes())
2031 }
2032}
2033
2034#[allow(
2036 clippy::too_many_lines,
2037 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"
2038)]
2039fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
2040 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
2041 "project.repo",
2042 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
2043 )];
2044 if let Some(list) = &config.profile.technologies {
2045 values.push(("profile.technologies", Some(array(list))));
2046 }
2047 if let Some(forge) = config.profile.forge.clone() {
2048 values.push(("profile.forge", Some(forge.into())));
2049 }
2050 if let Some(mode) = config.profile.release.mode {
2051 values.push(("profile.release.mode", Some(mode.as_str().into())));
2052 values.push((
2053 "profile.release.driver",
2054 config
2055 .profile
2056 .release
2057 .driver
2058 .clone()
2059 .map(toml_edit::Value::from),
2060 ));
2061 values.push((
2062 "profile.release.style",
2063 config
2064 .profile
2065 .release
2066 .style
2067 .map(|style| style.as_str().into()),
2068 ));
2069 values.push((
2070 "profile.release.line_prefix",
2071 config
2072 .profile
2073 .release
2074 .line_prefix
2075 .clone()
2076 .map(toml_edit::Value::from),
2077 ));
2078 values.push((
2079 "profile.release.registry",
2080 config
2081 .profile
2082 .release
2083 .registry
2084 .map(|answer| answer.as_str().into()),
2085 ));
2086 }
2087 if let Some(trunk) = config.git.trunk.clone() {
2088 values.push(("git.trunk", Some(trunk.into())));
2089 }
2090 if let Some(mode) = config.git.checkout_mode {
2091 values.push(("git.checkout_mode", Some(mode.as_str().into())));
2092 }
2093 if let Some(mode) = config.git.integration {
2094 values.push(("git.integration", Some(mode.as_str().into())));
2095 let mut bypass = toml_edit::Array::new();
2099 for actor in &config.protection.bypass_actors {
2100 bypass.push(actor.as_str());
2101 }
2102 values.push((
2103 "protection.bypass_actors",
2104 Some(toml_edit::Value::Array(bypass)),
2105 ));
2106 let mut rules = toml_edit::Array::new();
2107 for rule in &config.protection.owned_trunk_rules {
2108 rules.push(rule.as_str());
2109 }
2110 values.push((
2111 "protection.owned_trunk_rules",
2112 Some(toml_edit::Value::Array(rules)),
2113 ));
2114 values.push((
2115 "protection.gitlab.push_access_level",
2116 Some(config.protection.gitlab.push_access_level.into()),
2117 ));
2118 }
2119 if let Some(form) = config.git.merge_form {
2120 values.push(("git.merge_form", Some(form.as_str().into())));
2121 let mut methods = toml_edit::Array::new();
2126 for method in &config.protection.allowed_merge_methods {
2127 methods.push(method.as_str());
2128 }
2129 values.push((
2130 "protection.allowed_merge_methods",
2131 Some(toml_edit::Value::Array(methods)),
2132 ));
2133 values.push((
2134 "protection.gitlab.squash_option",
2135 Some(config.protection.gitlab.squash_option.clone().into()),
2136 ));
2137 }
2138 if let Some(value) = config.capabilities.nix_packaging {
2139 values.push(("capabilities.nix_packaging", Some(value.into())));
2140 }
2141 if let Some(value) = config.capabilities.reporting_policy {
2142 values.push(("capabilities.reporting_policy", Some(value.into())));
2143 }
2144 if let Some(value) = config.capabilities.scorecard {
2145 values.push(("capabilities.scorecard", Some(value.into())));
2146 }
2147 if let Some(value) = config.capabilities.code_scanning.clone() {
2148 values.push(("capabilities.code_scanning", Some(value.into())));
2149 }
2150 if let Some(value) = config.capabilities.workflow_guards {
2151 values.push(("capabilities.workflow_guards", Some(value.into())));
2152 }
2153 if let Some(value) = config.security.contact.clone() {
2156 values.push(("security.contact", Some(value.into())));
2157 }
2158 if let Some(value) = config.security.response.clone() {
2159 values.push(("security.response", Some(value.into())));
2160 }
2161 if let Some(value) = config.security.scope.clone() {
2162 values.push(("security.scope", Some(value.into())));
2163 }
2164 values.push((
2169 "setup.required_check",
2170 Some(config.setup.required_check.clone().into()),
2171 ));
2172 values.push((
2173 "setup.required_workflow",
2174 Some(config.setup.required_workflow.clone().into()),
2175 ));
2176 values
2177}
2178
2179#[must_use]
2181pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
2182 let recorded = {
2183 let params = crate::landing::Params::from_record(record);
2184 Plan::compose(None, ¶ms, None, None)
2185 .ok()
2186 .and_then(|plan| parse(&plan.content).ok())
2187 };
2188 let Some(recorded) = recorded else {
2189 return Vec::new();
2190 };
2191 let render = |value: &Option<toml_edit::Value>| {
2192 value.as_ref().map_or_else(
2193 || "<absent>".to_owned(),
2194 |value| value.to_string().trim().to_owned(),
2195 )
2196 };
2197 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
2198 .iter()
2199 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2200 .map(|(key, value)| (*key, render(value)))
2201 .collect();
2202 let mut keys: Vec<String> = parameter_values(config)
2203 .into_iter()
2204 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2205 .map(|(key, value)| (key, render(&value)))
2206 .filter(|(key, value)| {
2207 baseline
2208 .iter()
2209 .any(|(other, old)| key == other && value != old)
2210 })
2211 .map(|(key, _)| key.to_owned())
2212 .collect();
2213 if let Some(answer) = &config.dev_environment
2217 && answer.clone().answered() != record.parameters.dev_environment
2218 {
2219 keys.push("dev_environment".to_owned());
2220 }
2221 keys
2222}
2223
2224#[must_use]
2232pub(crate) fn local_protection() -> Protection {
2233 const MAINTAINER: i64 = 40;
2235 Protection {
2236 bypass_actors: vec![LOCAL_GITHUB_BYPASS.into()],
2237 gitlab: Gitlab {
2238 push_access_level: MAINTAINER,
2239 ..Gitlab::default()
2240 },
2241 ..Protection::default()
2242 }
2243}
2244
2245#[must_use]
2252pub const fn merge_values(form: MergeForm) -> (&'static str, &'static str) {
2253 match form {
2254 MergeForm::Squash => ("squash", "always"),
2255 MergeForm::Rebase => ("rebase", "never"),
2256 }
2257}
2258
2259#[must_use]
2264pub fn with_merge_form(policy: &Protection, form: MergeForm) -> Protection {
2265 let (method, squash_option) = merge_values(form);
2266 let mut next = policy.clone();
2267 next.allowed_merge_methods = vec![method.to_owned()];
2268 squash_option.clone_into(&mut next.gitlab.squash_option);
2269 next
2270}
2271
2272#[must_use]
2275pub(crate) fn legacy_local_protection(policy: &Protection) -> bool {
2276 policy.bypass_actors.is_empty()
2277 && policy.owned_trunk_rules == ["deletion", "non_fast_forward"]
2278 && policy.gitlab.push_access_level >= 40
2279}
2280
2281#[must_use]
2303fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
2304 let forge = Protection::default();
2305 let local = local_protection();
2306 let tuple = |policy: &Protection| {
2307 (
2308 policy.bypass_actors.clone(),
2309 policy.owned_trunk_rules.clone(),
2310 policy.gitlab.push_access_level,
2311 )
2312 };
2313 let taken = fresh
2314 || tuple(held) == tuple(&forge)
2315 || tuple(held) == tuple(&local)
2316 || legacy_local_protection(held);
2317 if !taken {
2318 return held.clone();
2319 }
2320 let mut next = held.clone();
2321 let source = match integration {
2322 Integration::Forge => forge,
2323 Integration::Local => local,
2324 };
2325 next.bypass_actors = source.bypass_actors;
2326 next.owned_trunk_rules = source.owned_trunk_rules;
2327 next.gitlab.push_access_level = source.gitlab.push_access_level;
2328 next
2329}
2330
2331pub fn trunk_of(target: &Path) -> Result<String, RkError> {
2336 Ok(load(target)?
2337 .and_then(|config| config.git.trunk)
2338 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
2339}
2340
2341pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
2346 Ok(load(target)?
2347 .and_then(|config| config.profile.release.line_prefix)
2348 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
2349}
2350
2351#[cfg(test)]
2352mod tests {
2353 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, set_migration, trunk_of, write};
2354 use crate::landing::{CheckoutMode, Integration, MergeForm, Style};
2355 use crate::profile::ReleaseMode;
2356
2357 fn resolved_defaults() -> Config {
2360 Config {
2361 project: super::Project {
2362 repo: "acme/widget".into(),
2363 },
2364 profile: super::Profile {
2365 technologies: Some(vec!["rust".into()]),
2366 forge: Some("github".into()),
2367 release: super::Release {
2368 mode: Some(ReleaseMode::Automatic),
2369 driver: Some("rust".into()),
2370 style: Some(Style::Trunk),
2371 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
2372 registry: None,
2373 },
2374 },
2375 git: super::Git {
2376 trunk: Some(super::TRUNK_DEFAULT.into()),
2377 checkout_mode: Some(CheckoutMode::LinkedWorktree),
2378 integration: Some(Integration::Local),
2379 merge_form: Some(MergeForm::Squash),
2380 },
2381 capabilities: super::Capabilities {
2382 nix_packaging: Some(false),
2383 reporting_policy: Some(true),
2384 scorecard: Some(false),
2385 code_scanning: Some("off".to_owned()),
2386 workflow_guards: Some(false),
2387 },
2388 security: super::Security {
2391 contact: Some(String::new()),
2392 response: Some(super::RESPONSE_DEFAULT.into()),
2393 scope: Some(String::new()),
2394 ..super::Security::default()
2395 },
2396 protection: super::Protection {
2399 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
2400 safety_ruleset: Some(format!("{}-safety", super::TRUNK_DEFAULT)),
2401 ..super::local_protection()
2402 },
2403 ..Config::default()
2404 }
2405 }
2406
2407 #[test]
2410 fn a_symlinked_configuration_path_refuses_every_writer() {
2411 let outside = tempfile::tempdir().expect("an outside directory exists");
2412 let text = "schema_version = 2\n";
2413 std::fs::write(outside.path().join("config.toml"), text).expect("writes");
2414 let linked_dir = tempfile::tempdir().expect("a tempdir");
2415 std::os::unix::fs::symlink(outside.path(), linked_dir.path().join(".release-kit"))
2416 .expect("links the directory");
2417 let linked_file = tempfile::tempdir().expect("a tempdir");
2418 std::fs::create_dir_all(linked_file.path().join(".release-kit")).expect("mkdir");
2419 std::os::unix::fs::symlink(
2420 outside.path().join("config.toml"),
2421 linked_file.path().join(CONFIG_PATH),
2422 )
2423 .expect("links the file");
2424 for dir in [&linked_dir, &linked_file] {
2425 let target = dir.path();
2426 let refusals = [
2427 write(target, &resolved_defaults()).expect_err("write refuses"),
2428 rewrite_key(target, "git.trunk", "main".into()).expect_err("rewrite refuses"),
2429 set_migration(target, Some("20261009T140205Z")).expect_err("the key refuses"),
2430 ];
2431 for error in refusals {
2432 assert_eq!(error.exit_code(), 73, "{error}");
2433 assert!(error.to_string().contains("symlink"), "{error}");
2434 }
2435 }
2436 assert_eq!(
2437 std::fs::read_to_string(outside.path().join("config.toml")).expect("reads"),
2438 text
2439 );
2440 }
2441
2442 #[test]
2443 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
2444 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
2445 let explicit = parse(
2446 "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",
2447 )
2448 .expect("explicit defaults parse");
2449 assert_eq!(omitted.git, super::Git::default());
2450 assert_eq!(omitted.capabilities, super::Capabilities::default());
2451 assert_eq!(
2452 explicit.git.checkout_mode,
2453 Some(CheckoutMode::LinkedWorktree)
2454 );
2455 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
2456 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
2457 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
2458 assert_eq!(explicit.capabilities.scorecard, Some(false));
2459 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
2460 assert_ne!(omitted, explicit);
2461 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
2463 .expect("the older spelling reads");
2464 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
2465 }
2466
2467 #[test]
2471 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
2472 let dir = tempfile::tempdir().expect("a tempdir");
2473 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
2474 std::fs::write(
2475 dir.path().join(CONFIG_PATH),
2476 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
2477 )
2478 .expect("the config writes");
2479 let held = load(dir.path())
2480 .expect("the config reads")
2481 .expect("it is present");
2482 assert_eq!(held.setup.bot.app_id, "123");
2483 assert_eq!(
2484 held.setup.bot.installation_id,
2485 Some(0),
2486 "the key parses; nothing reads it"
2487 );
2488 }
2489
2490 #[test]
2492 fn a_schema_1_config_migrates_into_its_domains() {
2493 let held = parse(
2494 "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",
2495 )
2496 .expect("a schema 1 file reads");
2497 assert_eq!(held.schema_version, 2);
2498 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
2499 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
2500 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
2501 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
2502 assert_eq!(held.profile.release.style, Some(Style::Lines));
2503 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
2504 assert_eq!(held.git.trunk.as_deref(), Some("main"));
2505 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
2506 assert_eq!(held.capabilities.nix_packaging, Some(false));
2507 assert_eq!(held.capabilities.reporting_policy, Some(true));
2508 }
2509
2510 #[test]
2512 fn every_invalid_release_state_names_its_key() {
2513 for (text, key) in [
2514 (
2515 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
2516 "profile.release.style",
2517 ),
2518 (
2519 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
2520 "profile.release.driver",
2521 ),
2522 (
2523 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
2524 "profile.release.line_prefix",
2525 ),
2526 (
2527 "[profile]\ntechnologies = ['rust', 'rust']\n",
2528 "profile.technologies",
2529 ),
2530 (
2531 "[profile]\ntechnologies = ['Rust!']\n",
2532 "profile.technologies",
2533 ),
2534 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
2535 (
2536 "[profile.release]\nmode = 'manual'\n",
2537 "profile.release.mode",
2538 ),
2539 ] {
2540 let error = parse(&format!("schema_version = 2\n{text}"))
2541 .expect_err("an invalid release state refuses")
2542 .to_string();
2543 assert!(error.contains(key), "{key}: {error}");
2544 }
2545 }
2546
2547 #[test]
2551 fn a_newly_written_key_takes_the_templates_comment() {
2552 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
2553 let next = super::rewrite_text(
2554 text,
2555 "git.integration",
2556 Some(toml_edit::Value::from("forge")),
2557 )
2558 .expect("the key writes");
2559 assert!(
2560 next.contains("integration = \"forge\" # P: local or forge"),
2561 "{next}"
2562 );
2563 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
2565 .expect("the key writes");
2566 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
2567 }
2568
2569 #[test]
2570 fn the_landed_config_template_round_trips() {
2571 let dir = tempfile::tempdir().expect("a target exists");
2572 let mut config = Config::default();
2573 config.project.repo = "acme/nested/widget".into();
2574 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
2575 config.profile.forge = Some("gitlab".into());
2576 config.profile.release = super::Release {
2577 mode: Some(ReleaseMode::Automatic),
2578 driver: Some("bash".into()),
2579 style: Some(Style::Lines),
2580 line_prefix: Some("stable/".into()),
2581 registry: None,
2582 };
2583 config.git.trunk = Some("main".into());
2584 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
2585 config.git.integration = Some(Integration::Forge);
2586 config.git.merge_form = Some(MergeForm::Rebase);
2587 config.protection = super::with_merge_form(&config.protection, MergeForm::Rebase);
2588 config.capabilities.nix_packaging = Some(true);
2589 config.capabilities.reporting_policy = Some(false);
2590 config.capabilities.scorecard = Some(true);
2591 config.capabilities.code_scanning = Some("semgrep".to_owned());
2592 config.capabilities.workflow_guards = Some(true);
2593 config.security.advisories =
2597 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
2598 config.security.contact = Some("security team, room 3 \"the vault\"".into());
2599 config.security.response = Some("14 business days".into());
2600 config.security.scope =
2603 Some("Wraps the \"claude\" CLI; report it upstream.\nRK_CONFIG_SECURITY_SCOPE stays literal.".into());
2604 config.setup.required_check = "build / test".into();
2605 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
2606 config.setup.release_lines = true;
2607 config.setup.excluded_steps = [
2608 (
2609 "package-check".to_owned(),
2610 "nothing is published".to_owned(),
2611 ),
2612 (
2613 "protect-trunk".to_owned(),
2614 "this project merges \"locally\"".to_owned(),
2615 ),
2616 ]
2617 .into_iter()
2618 .collect();
2619 config.setup.bot.app_id = "123".into();
2620 config.protection.trunk_ruleset = Some("primary".into());
2621 config.protection.safety_ruleset = Some("guardrail".into());
2622 config.protection.tag_ruleset = "versions".into();
2623 config.protection.lines_ruleset = "maintenance".into();
2624 config.protection.title_check = "intent".into();
2625 config.protection.tag_pattern = "refs/tags/*".into();
2626 config
2627 .protection
2628 .owned_trunk_rules
2629 .push("required_signatures".into());
2630 config.protection.required_approving_review_count = 2;
2631 config.protection.dismiss_stale_reviews_on_push = true;
2632 config.protection.require_code_owner_review = true;
2633 config.protection.require_last_push_approval = true;
2634 config.protection.gitlab.squash_commit_template =
2635 "%{title}\n\nContext: %{description}".into();
2636 config.protection.gitlab.merge_access_level = 40;
2637 let mut release_less = resolved_defaults();
2640 release_less.project.repo = String::new();
2641 release_less.profile.technologies = Some(Vec::new());
2642 release_less.profile.forge = Some(String::new());
2643 release_less.profile.release = super::Release {
2644 mode: Some(ReleaseMode::None),
2645 driver: None,
2646 style: None,
2647 line_prefix: None,
2648 registry: None,
2649 };
2650 release_less.capabilities.reporting_policy = Some(false);
2651 release_less.security.contact = None;
2654 release_less.security.response = None;
2655 release_less.security.scope = None;
2656 for expected in [resolved_defaults(), config, release_less] {
2657 write(dir.path(), &expected).expect("the template renders");
2658 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
2659 let text =
2660 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2661 assert!(text.contains("# P: every technology"));
2662 assert!(text.contains("# F: invariant"));
2663 }
2664 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2665 assert!(
2666 !text.contains("style ="),
2667 "a none release writes no style: {text}"
2668 );
2669 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
2670 }
2671
2672 #[test]
2673 fn a_config_with_an_unknown_key_refuses_by_name() {
2674 for (table, typo, nearest) in [
2675 ("", "schemax_version", "schema_version"),
2676 ("git", "trunkx", "trunk"),
2677 ("profile.release", "stile", "style"),
2678 ("capabilities", "nix_packagingg", "nix_packaging"),
2679 ("security", "contactx", "contact"),
2680 ("setup", "required_checkx", "required_check"),
2681 ("setup.bot", "app_i", "app_id"),
2682 ("protection", "trunk_rulesett", "trunk_ruleset"),
2683 (
2684 "protection.github",
2685 "squash_body_sourcex",
2686 "squash_body_source",
2687 ),
2688 ("protection.gitlab", "squash_optionx", "squash_option"),
2689 ] {
2690 let header = if table.is_empty() {
2691 String::new()
2692 } else {
2693 format!("[{table}]\n")
2694 };
2695 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
2696 let error = parse(&text).expect_err("unknown keys refuse").to_string();
2697 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
2698 assert!(error.contains(expected), "{error}");
2699 }
2700 }
2701 }
2702
2703 #[test]
2707 fn an_exclusion_names_a_real_step_and_states_why() {
2708 for (text, expected) in [
2709 (
2710 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
2711 vec!["protect-trunkk", "nearest known step: protect-trunk"],
2712 ),
2713 (
2714 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
2715 vec!["protect-trunk", "no reason"],
2716 ),
2717 ] {
2718 let error = parse(&format!("schema_version = 2\n{text}"))
2719 .expect_err("the exclusion refuses")
2720 .to_string();
2721 for want in expected {
2722 assert!(error.contains(want), "{error}");
2723 }
2724 }
2725 let held = parse(
2726 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
2727 )
2728 .expect("a named step with a reason parses");
2729 assert_eq!(
2730 held.setup
2731 .excluded_steps
2732 .get("protect-trunk")
2733 .map(String::as_str),
2734 Some("we merge locally")
2735 );
2736 }
2737
2738 #[test]
2742 fn an_exclusion_does_not_lift_a_floor() {
2743 let error = parse(
2744 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
2745 )
2746 .expect_err("the floor binds an excluded step's keys too")
2747 .to_string();
2748 assert!(
2749 error.contains("protection.allowed_merge_methods"),
2750 "{error}"
2751 );
2752 }
2753
2754 #[test]
2755 fn a_config_at_an_unknown_schema_refuses() {
2756 for text in ["schema_version = 999", "schema_version = '2'", ""] {
2757 let error = parse(text)
2758 .expect_err("a schema must be declared and known")
2759 .to_string();
2760 assert!(
2761 error.contains(CONFIG_PATH) && error.contains("schema_version"),
2762 "{error}"
2763 );
2764 }
2765 }
2766
2767 #[test]
2768 fn an_unparsable_config_refuses_naming_the_position() {
2769 let error = parse("schema_version = 2\n[project\n")
2770 .expect_err("bad TOML refuses")
2771 .to_string();
2772 for expected in [CONFIG_PATH, "line 2", "column"] {
2773 assert!(error.contains(expected), "{error}");
2774 }
2775 }
2776
2777 #[test]
2778 fn an_absent_config_reads_as_none() {
2779 let dir = tempfile::tempdir().expect("a target exists");
2780 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2781 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2782 }
2783
2784 #[test]
2785 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2786 let dir = tempfile::tempdir().expect("a target exists");
2787 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2788 std::fs::write(
2789 dir.path().join(CONFIG_PATH),
2790 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2791 )
2792 .expect("a config exists");
2793 let error =
2794 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2795 assert_eq!(error.exit_code(), 73);
2796 assert!(
2797 error
2798 .to_string()
2799 .contains("protection.strict_required_status_checks")
2800 );
2801 }
2802
2803 #[test]
2806 fn an_equal_value_keeps_its_spelling() {
2807 let original = "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'tr\\u0075nk' # ours\n";
2808 let original = original.replace("'tr\\u0075nk'", "\"tr\\u0075nk\"");
2809 let text = super::rewrite_text(&original, "profile.release.style", Some("trunk".into()))
2810 .expect("the style rewrites");
2811 assert_eq!(text, original, "an equal value is rewritten");
2812 let quoted =
2813 "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk' # ours\n";
2814 let text = super::rewrite_text(quoted, "profile.release.style", Some("trunk".into()))
2815 .expect("the style rewrites");
2816 assert_eq!(
2817 text, quoted,
2818 "a literal string equal to the value is rewritten"
2819 );
2820 }
2821
2822 #[test]
2823 fn rewrite_key_preserves_comments() {
2824 let dir = tempfile::tempdir().expect("a target exists");
2825 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2826 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";
2827 let path = dir.path().join(CONFIG_PATH);
2828 std::fs::write(&path, original).expect("a config exists");
2829 rewrite_key(dir.path(), "profile.release.style", "lines".into())
2830 .expect("the style writes back");
2831 let text = std::fs::read_to_string(&path).expect("the text reads");
2832 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2833 assert_eq!(
2834 load(dir.path())
2835 .expect("the config reads")
2836 .expect("present")
2837 .profile
2838 .release
2839 .style,
2840 Some(Style::Lines)
2841 );
2842 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2843 .expect("an omitted table can be added");
2844 assert_eq!(
2845 load(dir.path())
2846 .expect("reads")
2847 .expect("present")
2848 .project
2849 .repo,
2850 "acme/widget"
2851 );
2852 rewrite_key(
2853 dir.path(),
2854 "security.contact",
2855 "security@acme.example".into(),
2856 )
2857 .expect("the contact is a landing parameter");
2858 rewrite_key(dir.path(), "security.response", "14 days".into())
2859 .expect("the response is a landing parameter");
2860 let held = load(dir.path()).expect("reads").expect("present");
2861 assert_eq!(
2862 held.security.contact.as_deref(),
2863 Some("security@acme.example")
2864 );
2865 assert_eq!(held.security.response.as_deref(), Some("14 days"));
2866 let text = std::fs::read_to_string(&path).expect("the text reads");
2867 assert!(text.contains("# keep me"), "the comment survives: {text}");
2868 let before = std::fs::read(&path).expect("the bytes read");
2869 for (key, value) in [
2870 ("security.advisories", "acme/private"),
2871 ("security.response", "90d"),
2872 ("profile.release.style", "unknown"),
2873 ] {
2874 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2875 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2876 }
2877 }
2878
2879 #[test]
2882 fn the_security_answers_are_held_to_their_grammar() {
2883 for value in ["team@acme.example", " https://acme.example/report ", ""] {
2884 super::canonical_contact(value).expect("a control-free line is a contact");
2885 }
2886 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2887 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2888 assert!(refusal.contains("security.contact"), "{refusal}");
2889 }
2890 assert_eq!(
2891 super::canonical_contact(" team@acme.example "),
2892 Ok("team@acme.example".to_owned()),
2893 "surrounding whitespace is trimmed"
2894 );
2895 for value in [
2896 "best-effort",
2897 "1 day",
2898 "2 days",
2899 "14 days",
2900 "1 business day",
2901 "14 business days",
2902 ] {
2903 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2904 }
2905 assert_eq!(
2906 super::canonical_response(""),
2907 Ok(super::RESPONSE_DEFAULT.to_owned()),
2908 "an empty answer reads as the compiled default"
2909 );
2910 for value in [
2911 "0 days",
2912 "1 days",
2913 "2 day",
2914 "+2 days",
2915 "02 days",
2916 "4294967296 days",
2917 "90d",
2918 "two days",
2919 "we answer quickly",
2920 "2 weeks",
2921 ] {
2922 let refusal =
2923 super::canonical_response(value).expect_err("an unstateable window refuses");
2924 assert!(refusal.contains("security.response"), "{value}: {refusal}");
2925 assert!(refusal.contains("business days"), "{value}: {refusal}");
2926 }
2927 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2928 .expect_err("the reader refuses it too")
2929 .to_string();
2930 assert!(refusal.contains("security.response"), "{refusal}");
2931 }
2932
2933 #[test]
2936 fn the_security_scope_is_one_paragraph() {
2937 for value in [
2938 "",
2939 "This wraps the claude CLI. Report a flaw in it upstream.",
2940 "One line,\nand a second line of the same paragraph.",
2941 "A #hashtag mid-line and a <comment> stay prose.",
2942 "A dash - and an = sign mid-line,\nand a line ending in --- stay prose.",
2943 ] {
2944 assert_eq!(super::canonical_scope(value), Ok(value.to_owned()));
2945 }
2946 assert_eq!(
2947 super::canonical_scope(" wrapped \n"),
2948 Ok("wrapped".to_owned()),
2949 "surrounding whitespace is trimmed"
2950 );
2951 for (value, why) in [
2952 ("one\n\ntwo", "blank line"),
2953 ("one\n \ntwo", "blank line"),
2954 ("one\r\ntwo", "control character"),
2955 ("one\ttwo", "control character"),
2956 ("# Out of scope", "heading"),
2957 ("intro\n ## Out of scope", "heading"),
2958 ("Out of scope\n===", "underlines a Markdown heading"),
2959 ("Out of scope\n --- ", "underlines a Markdown heading"),
2960 ("see <!-- here", "comment delimiter"),
2961 ("here --> there", "comment delimiter"),
2962 ] {
2963 let refusal =
2964 super::canonical_scope(value).expect_err("a policy-breaking scope refuses");
2965 assert!(refusal.contains("security.scope"), "{value:?}: {refusal}");
2966 assert!(refusal.contains(why), "{value:?}: {refusal}");
2967 }
2968 }
2969
2970 #[test]
2974 fn a_migration_key_that_is_no_identifier_refuses() {
2975 for id in ["20261009T140205Z", "20261009T140205Z-2"] {
2976 let config = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2977 .expect("an identifier parses");
2978 assert_eq!(config.migration.as_deref(), Some(id));
2979 }
2980 for id in [
2981 "../../elsewhere",
2982 "20261009T140205Z-1",
2983 "",
2984 "20261009T140205\u{e9}",
2985 "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
2986 ] {
2987 let refusal = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2988 .expect_err("a value that is no identifier refuses")
2989 .to_string();
2990 assert!(
2991 refusal.contains("no migration identifier"),
2992 "{id}: {refusal}"
2993 );
2994 }
2995 }
2996
2997 #[test]
3002 fn a_pruned_header_leaves_no_comment_behind() {
3003 let cases = [
3004 (
3005 "a middle table, comment above",
3006 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
3007 ),
3008 (
3009 "a middle table, comment inline",
3010 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
3011 ),
3012 (
3013 "the last table, comment above",
3014 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
3015 ),
3016 (
3017 "the last table, comment inline",
3018 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
3019 ),
3020 ];
3021 for (case, text) in cases {
3022 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
3023 assert!(!next.contains("repo ="), "{case}: {next}");
3024 assert!(
3025 !next.contains("[project]"),
3026 "{case}: a table every one of whose keys dropped goes with them: {next}"
3027 );
3028 assert!(
3029 next.contains("# the operator's note"),
3030 "{case}: the comment the header carried survives: {next}"
3031 );
3032 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
3033 }
3034 }
3035
3036 #[test]
3042 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
3043 let text = concat!(
3044 "schema_version = 2\n\n[project]\n",
3045 "# the operator's note\n",
3046 "repo = \"acme/widget\" # P: project path on the forge\n\n",
3047 "[git]\ntrunk = \"main\"\n"
3048 );
3049 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
3050 assert!(!next.contains("repo ="), "{next}");
3051 assert!(!next.contains("[project]"), "{next}");
3052 assert!(
3053 next.contains("# the operator's note"),
3054 "authored text survives: {next}"
3055 );
3056 assert!(
3057 !next.contains("# P:"),
3058 "the template's comment describes a key that is gone: {next}"
3059 );
3060 parse(&next).expect("the result parses");
3061
3062 let text = concat!(
3064 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
3065 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
3066 "# why this project names its own prefix\n",
3067 "line_prefix = \"stable/\"\n"
3068 );
3069 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
3070 .expect("the key removes");
3071 assert!(!next.contains("line_prefix ="), "{next}");
3072 assert!(next.contains("[profile.release]"), "{next}");
3073 assert!(
3074 next.contains("# why this project names its own prefix"),
3075 "authored text survives: {next}"
3076 );
3077 }
3078}