1pub mod floors;
8pub mod migrate;
9
10use std::collections::BTreeMap;
11use std::fmt::Write as _;
12use std::path::Path;
13
14use crate::diagnostic::{Diagnostic, Reason};
15use crate::error::RkError;
16use crate::landing::{CheckoutMode, Integration, Style};
17use crate::profile::ReleaseMode;
18use serde::{Deserialize, Serialize};
19
20pub const CONFIG_PATH: &str = ".release-kit/config.toml";
22pub const SCHEMA_VERSION: i64 = 2;
25
26#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
28#[serde(deny_unknown_fields, default)]
29pub struct Config {
30 pub schema_version: i64,
32 pub migration: Option<String>,
36 pub project: Project,
38 pub profile: Profile,
40 pub git: Git,
42 pub capabilities: Capabilities,
44 pub security: Security,
46 pub setup: Setup,
48 pub protection: Protection,
50 pub acquisition: Option<Acquisition>,
52}
53
54impl Default for Config {
55 fn default() -> Self {
56 Self {
57 schema_version: SCHEMA_VERSION,
58 migration: None,
59 project: Project::default(),
60 profile: Profile::default(),
61 git: Git::default(),
62 capabilities: Capabilities::default(),
63 security: Security::default(),
64 setup: Setup::default(),
65 protection: Protection::default(),
66 acquisition: None,
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum AcquisitionSource {
75 Host,
77}
78
79impl AcquisitionSource {
80 #[must_use]
82 pub const fn as_str(self) -> &'static str {
83 match self {
84 Self::Host => "host",
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
97#[serde(deny_unknown_fields)]
98pub struct Acquisition {
99 pub source: AcquisitionSource,
101 pub minimum: String,
103}
104
105impl Acquisition {
106 #[must_use]
108 pub fn host_at_this_release() -> Self {
109 Self {
110 source: AcquisitionSource::Host,
111 minimum: env!("CARGO_PKG_VERSION").to_owned(),
112 }
113 }
114}
115
116fn is_release_version(value: &str) -> bool {
119 let core = value.split(['-', '+']).next().unwrap_or_default();
120 let parts: Vec<&str> = core.split('.').collect();
121 parts.len() == 3
122 && parts
123 .iter()
124 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
125 && value[core.len()..]
126 .chars()
127 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
132#[serde(deny_unknown_fields, default)]
133pub struct Project {
134 pub repo: String,
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
141#[serde(deny_unknown_fields, default)]
142pub struct Profile {
143 pub technologies: Option<Vec<String>>,
145 pub forge: Option<String>,
148 pub release: Release,
150}
151
152#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
154#[serde(deny_unknown_fields, default)]
155pub struct Release {
156 pub mode: Option<ReleaseMode>,
159 pub driver: Option<String>,
162 pub style: Option<Style>,
164 pub line_prefix: Option<String>,
166}
167
168#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
170#[serde(deny_unknown_fields, default)]
171pub struct Git {
172 pub trunk: Option<String>,
176 pub checkout_mode: Option<CheckoutMode>,
178 pub integration: Option<Integration>,
181}
182
183#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
185#[serde(deny_unknown_fields, default)]
186pub struct Capabilities {
187 pub nix_packaging: Option<bool>,
189 pub reporting_policy: Option<bool>,
191 pub scorecard: Option<bool>,
193 pub code_scanning: Option<String>,
197 pub workflow_guards: Option<bool>,
200}
201
202pub const TRUNK_DEFAULT: &str = "master";
204
205pub const LINE_PREFIX_DEFAULT: &str = "release/";
207
208#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
210#[serde(deny_unknown_fields, default)]
211pub struct Security {
212 pub advisories: String,
214 pub contact: Option<String>,
219 pub response: Option<String>,
222}
223
224pub const RESPONSE_DEFAULT: &str = "best-effort";
227
228pub const LOCAL_GITHUB_BYPASS: &str = "repository-admin";
234
235pub fn canonical_contact(raw: &str) -> Result<String, String> {
245 let trimmed = raw.trim();
246 if trimmed.chars().any(char::is_control) {
247 return Err(format!(
248 "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:?}"
249 ));
250 }
251 Ok(trimmed.to_owned())
252}
253
254pub fn canonical_response(raw: &str) -> Result<String, String> {
266 let trimmed = raw.trim();
267 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
268 return Ok(RESPONSE_DEFAULT.to_owned());
269 }
270 let refusal = || {
271 format!(
272 "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:?}"
273 )
274 };
275 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
276 let plural = match unit {
277 "day" | "business day" => false,
278 "days" | "business days" => true,
279 _ => return Err(refusal()),
280 };
281 let number: u32 = count.parse().map_err(|_| refusal())?;
282 if count != number.to_string() || (number > 1) != plural {
285 return Err(refusal());
286 }
287 Ok(trimmed.to_owned())
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
292#[serde(deny_unknown_fields, default)]
293pub struct Setup {
294 pub required_check: String,
297 pub required_workflow: String,
301 pub retired_branches: Vec<String>,
303 pub release_lines: bool,
305 pub excluded_steps: BTreeMap<String, String>,
310 pub bot: Bot,
312}
313
314impl Default for Setup {
315 fn default() -> Self {
316 Self {
317 required_check: String::new(),
318 required_workflow: String::new(),
319 retired_branches: vec!["main".into(), "develop".into()],
320 release_lines: false,
321 excluded_steps: BTreeMap::new(),
322 bot: Bot::default(),
323 }
324 }
325}
326
327#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
334#[serde(deny_unknown_fields, default)]
335pub struct Bot {
336 pub app_id: String,
338 #[serde(default, skip_serializing)]
343 pub installation_id: Option<i64>,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
348#[serde(deny_unknown_fields, default)]
349#[allow(
350 clippy::struct_excessive_bools,
351 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
352)]
353pub struct Protection {
354 pub trunk_ruleset: Option<String>,
358 pub safety_ruleset: Option<String>,
363 pub tag_ruleset: String,
365 pub lines_ruleset: String,
367 pub title_check: String,
369 pub tag_pattern: String,
371 pub bypass_actors: Vec<String>,
374 pub allowed_merge_methods: Vec<String>,
376 pub strict_required_status_checks: bool,
378 pub owned_trunk_rules: Vec<String>,
382 pub required_approving_review_count: i64,
384 pub dismiss_stale_reviews_on_push: bool,
386 pub require_code_owner_review: bool,
388 pub require_last_push_approval: bool,
390 pub github: Github,
392 pub gitlab: Gitlab,
394}
395
396impl Default for Protection {
397 fn default() -> Self {
398 Self {
399 trunk_ruleset: None,
400 safety_ruleset: None,
401 tag_ruleset: "release-tags".into(),
402 lines_ruleset: "release-lines".into(),
403 title_check: "pr-title".into(),
404 tag_pattern: "refs/tags/v*".into(),
405 bypass_actors: Vec::new(),
406 allowed_merge_methods: vec!["squash".into()],
407 strict_required_status_checks: true,
408 owned_trunk_rules: vec![
409 "deletion".into(),
410 "non_fast_forward".into(),
411 "pull_request".into(),
412 "required_status_checks".into(),
413 ],
414 required_approving_review_count: 0,
415 dismiss_stale_reviews_on_push: false,
416 require_code_owner_review: false,
417 require_last_push_approval: false,
418 github: Github::default(),
419 gitlab: Gitlab::default(),
420 }
421 }
422}
423
424impl Protection {
425 #[must_use]
428 pub fn trunk_ruleset(&self, trunk: &str) -> String {
429 self.trunk_ruleset
430 .clone()
431 .unwrap_or_else(|| format!("{trunk}-protection"))
432 }
433
434 #[must_use]
441 pub fn safety_ruleset(&self, trunk: &str) -> String {
442 self.safety_ruleset
443 .clone()
444 .unwrap_or_else(|| format!("{trunk}-safety"))
445 }
446}
447
448pub const SAFETY_RULES: [&str; 2] = ["deletion", "non_fast_forward"];
455
456pub const REQUEST_RULES: [&str; 2] = ["pull_request", "required_status_checks"];
459
460#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
462#[serde(deny_unknown_fields, default)]
463pub struct Github {
464 pub squash_title_source: String,
466 pub squash_body_source: String,
468}
469
470impl Default for Github {
471 fn default() -> Self {
472 Self {
473 squash_title_source: "PR_TITLE".into(),
474 squash_body_source: "PR_BODY".into(),
475 }
476 }
477}
478
479#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
481#[serde(deny_unknown_fields, default)]
482pub struct Gitlab {
483 pub merge_method: String,
485 pub squash_option: String,
487 pub squash_commit_template: String,
489 pub push_access_level: i64,
494 pub merge_access_level: i64,
496}
497
498impl Default for Gitlab {
499 fn default() -> Self {
500 Self {
501 merge_method: "ff".into(),
502 squash_option: "always".into(),
503 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
504 .trim_end_matches('\n')
505 .to_owned(),
506 push_access_level: 0,
507 merge_access_level: 40,
508 }
509 }
510}
511
512pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
517 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
518 Ok(text) => text,
519 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
520 Err(error) => return Err(error.into()),
521 };
522 parse(&text).map(Some)
523}
524
525fn current_text(text: &str) -> Result<String, RkError> {
528 let raw: toml::Value =
529 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
530 match raw.get("schema_version").and_then(toml::Value::as_integer) {
531 Some(1) => migrate::to_schema_2(text),
532 _ => Ok(text.to_owned()),
533 }
534}
535
536fn parse(text: &str) -> Result<Config, RkError> {
537 let text = current_text(text)?;
538 let raw: toml::Value =
539 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
540 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
541 return Err(invalid(format!(
542 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
543 )));
544 }
545 if let Some(mode) = raw
549 .get("profile")
550 .and_then(|profile| profile.get("release"))
551 .and_then(|release| release.get("mode"))
552 {
553 let named = mode.as_str().ok_or_else(|| {
554 invalid("profile.release.mode must be a string: automatic, external, or none")
555 })?;
556 crate::profile::ReleaseMode::parse(named)
557 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
558 }
559 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
560 let mut message = error.to_string();
561 if let Some(rest) = error.message().strip_prefix("unknown field `") {
562 let names: Vec<_> = rest.split('`').collect();
563 if let Some(unknown) = names.first()
564 && let Some(nearest) = names
565 .iter()
566 .skip(2)
567 .step_by(2)
568 .min_by_key(|name| distance(unknown, name))
569 {
570 let _ = write!(message, "; nearest known key: {nearest}");
571 }
572 }
573 invalid(message)
574 })?;
575 if let Some(technologies) = &config.profile.technologies {
576 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
577 }
578 if let Some(forge) = &config.profile.forge
579 && !forge.is_empty()
580 {
581 crate::profile::canonical_category(forge)
582 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
583 }
584 if let Some(driver) = &config.profile.release.driver {
585 crate::profile::canonical_category(driver)
586 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
587 }
588 if matches!(
589 config.profile.release.mode,
590 Some(ReleaseMode::External | ReleaseMode::None)
591 ) {
592 let mode = config
593 .profile
594 .release
595 .mode
596 .map_or("none", ReleaseMode::as_str);
597 for (key, present) in [
598 (
599 "profile.release.driver",
600 config.profile.release.driver.is_some(),
601 ),
602 (
603 "profile.release.style",
604 config.profile.release.style.is_some(),
605 ),
606 (
607 "profile.release.line_prefix",
608 config.profile.release.line_prefix.is_some(),
609 ),
610 ] {
611 if present {
612 return Err(invalid(format!(
613 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
614 )));
615 }
616 }
617 }
618 if let Some(contact) = &config.security.contact {
619 canonical_contact(contact).map_err(invalid)?;
620 }
621 if let Some(response) = &config.security.response {
622 canonical_response(response).map_err(invalid)?;
623 }
624 exclusions(&config.setup.excluded_steps)?;
625 if let Some(acquisition) = &config.acquisition
626 && !is_release_version(&acquisition.minimum)
627 {
628 return Err(invalid(format!(
629 "acquisition.minimum is {}, which is no release version; write one such as {}",
630 acquisition.minimum,
631 env!("CARGO_PKG_VERSION")
632 )));
633 }
634 migration_key(&config)?;
635 floors::check(&config)?;
636 Ok(config)
637}
638
639fn migration_key(config: &Config) -> Result<(), RkError> {
642 match &config.migration {
643 Some(id) if !crate::migrate::is_id(id) => Err(invalid(format!(
644 "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)"
645 ))),
646 _ => Ok(()),
647 }
648}
649
650fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
657 for (name, reason) in excluded {
658 if crate::setup::steps::spec(name).is_none() {
659 let nearest = crate::setup::steps::STEPS
660 .iter()
661 .min_by_key(|step| distance(name, step.name))
662 .map_or("", |step| step.name);
663 return Err(invalid(format!(
664 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
665 )));
666 }
667 if reason.trim().is_empty() {
668 return Err(invalid(format!(
669 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
670 )));
671 }
672 }
673 Ok(())
674}
675
676pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
677 RkError::refusal(
678 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
679 .action(format!("edit {CONFIG_PATH} and retry"))
680 .target_state("nothing was written"),
681 )
682}
683
684fn distance(left: &str, right: &str) -> usize {
685 let mut row: Vec<_> = (0..=right.chars().count()).collect();
686 for (i, a) in left.chars().enumerate() {
687 let mut previous = row[0];
688 row[0] = i + 1;
689 for (j, b) in right.chars().enumerate() {
690 let old = row[j + 1];
691 row[j + 1] = (previous + usize::from(a != b))
692 .min(row[j] + 1)
693 .min(old + 1);
694 previous = old;
695 }
696 }
697 row.last().copied().unwrap_or(0)
698}
699
700pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
706 let bytes = render(config)?;
707 parse(&String::from_utf8_lossy(&bytes))?;
708 write_checked(target, &bytes)
709}
710
711fn write_checked(target: &Path, bytes: &[u8]) -> Result<(), RkError> {
714 check_writable(target)?;
715 crate::atomic::write(&target.join(CONFIG_PATH), bytes)?;
716 Ok(())
717}
718
719fn array(values: &[String]) -> toml_edit::Value {
720 toml_edit::Value::Array(values.iter().collect())
721}
722
723fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
728 let mut table = toml_edit::InlineTable::new();
729 for (key, value) in values {
730 table.insert(key, value.clone().into());
731 }
732 toml_edit::Value::InlineTable(table)
733}
734
735#[allow(
740 clippy::too_many_lines,
741 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
742)]
743fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
744 let trunk = config
748 .git
749 .trunk
750 .clone()
751 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
752 let technologies = config
753 .profile
754 .technologies
755 .clone()
756 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
757 let forge = config
758 .profile
759 .forge
760 .clone()
761 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
762 let mode = config
763 .profile
764 .release
765 .mode
766 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
767 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
768 (
769 "RK_CONFIG_SCHEMA_VERSION",
770 Some(config.schema_version.into()),
771 ),
772 (
773 "RK_CONFIG_PROJECT_REPO",
774 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
775 ),
776 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
777 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
778 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
779 (
780 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
781 config
782 .profile
783 .release
784 .driver
785 .clone()
786 .map(toml_edit::Value::from),
787 ),
788 (
789 "RK_CONFIG_PROFILE_RELEASE_STYLE",
790 config
791 .profile
792 .release
793 .style
794 .map(|style| style.as_str().into()),
795 ),
796 (
797 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
798 config
799 .profile
800 .release
801 .line_prefix
802 .clone()
803 .map(toml_edit::Value::from),
804 ),
805 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
806 (
807 "RK_CONFIG_GIT_CHECKOUT_MODE",
808 Some(
809 config
810 .git
811 .checkout_mode
812 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
813 .as_str()
814 .into(),
815 ),
816 ),
817 (
818 "RK_CONFIG_GIT_INTEGRATION",
819 Some(
820 config
821 .git
822 .integration
823 .ok_or_else(|| invalid("git.integration is unresolved"))?
824 .as_str()
825 .into(),
826 ),
827 ),
828 (
829 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
830 Some(
831 config
832 .capabilities
833 .nix_packaging
834 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
835 .into(),
836 ),
837 ),
838 (
839 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
840 Some(
841 config
842 .capabilities
843 .reporting_policy
844 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
845 .into(),
846 ),
847 ),
848 (
849 "RK_CONFIG_CAPABILITIES_SCORECARD",
850 Some(
851 config
852 .capabilities
853 .scorecard
854 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
855 .into(),
856 ),
857 ),
858 (
859 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
860 Some(
861 config
862 .capabilities
863 .code_scanning
864 .clone()
865 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
866 .into(),
867 ),
868 ),
869 (
870 "RK_CONFIG_CAPABILITIES_WORKFLOW_GUARDS",
871 Some(
872 config
873 .capabilities
874 .workflow_guards
875 .ok_or_else(|| invalid("capabilities.workflow_guards is unresolved"))?
876 .into(),
877 ),
878 ),
879 (
880 "RK_CONFIG_SECURITY_ADVISORIES",
881 Some(config.security.advisories.clone().into()),
882 ),
883 (
884 "RK_CONFIG_SECURITY_CONTACT",
885 Some(config.security.contact.clone().unwrap_or_default().into()),
886 ),
887 (
888 "RK_CONFIG_SECURITY_RESPONSE",
889 Some(
890 config
891 .security
892 .response
893 .clone()
894 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
895 .into(),
896 ),
897 ),
898 (
899 "RK_CONFIG_SETUP_REQUIRED_CHECK",
900 Some(config.setup.required_check.clone().into()),
901 ),
902 (
903 "RK_CONFIG_SETUP_REQUIRED_WORKFLOW",
904 Some(config.setup.required_workflow.clone().into()),
905 ),
906 (
907 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
908 Some(array(&config.setup.retired_branches)),
909 ),
910 (
911 "RK_CONFIG_SETUP_RELEASE_LINES",
912 Some(config.setup.release_lines.into()),
913 ),
914 (
915 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
916 Some(inline(&config.setup.excluded_steps)),
917 ),
918 (
919 "RK_CONFIG_SETUP_BOT_APP_ID",
920 Some(config.setup.bot.app_id.clone().into()),
921 ),
922 (
923 "RK_CONFIG_ACQUISITION_SOURCE",
924 config
925 .acquisition
926 .as_ref()
927 .map(|answer| answer.source.as_str().into()),
928 ),
929 (
930 "RK_CONFIG_ACQUISITION_MINIMUM",
931 config
932 .acquisition
933 .as_ref()
934 .map(|answer| answer.minimum.clone().into()),
935 ),
936 ];
937 fields.extend(
938 protection_fields(&config.protection, trunk.as_str())
939 .into_iter()
940 .map(|(token, value)| (token, Some(value))),
941 );
942 Ok(fields)
943}
944
945fn render(config: &Config) -> Result<Vec<u8>, RkError> {
946 let fields = fields(config)?;
947 let template = crate::embedded::BLOCKS
948 .get_file("target-config.toml.in")
949 .and_then(include_dir::File::contents_utf8)
950 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
951 let lines: Vec<&str> = template.split_inclusive('\n').collect();
957 let dropped: Vec<bool> = lines
958 .iter()
959 .map(|line| {
960 matches!(
961 fields.iter().find(|(token, _)| line.contains(token)),
962 Some((_, None))
963 )
964 })
965 .collect();
966 let mut bytes = Vec::new();
967 let mut at = 0;
968 while at < lines.len() {
969 let line = lines[at];
970 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
971 let mut end = at + 1;
972 let mut keeps = false;
973 while end < lines.len()
974 && !(lines[end].trim_start().starts_with('[')
975 && lines[end].trim_end().ends_with(']'))
976 {
977 keeps |=
978 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
979 end += 1;
980 }
981 if !keeps {
982 while bytes.last() == Some(&b'\n')
984 && bytes.len() >= 2
985 && bytes[bytes.len() - 2] == b'\n'
986 {
987 bytes.pop();
988 }
989 at = end;
990 continue;
991 }
992 }
993 if !dropped[at] {
994 match fields.iter().find(|(token, _)| line.contains(token)) {
995 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
996 line.as_bytes(),
997 token.as_bytes(),
998 value.to_string().as_bytes(),
999 )),
1000 Some((_, None)) => {}
1001 None => bytes.extend_from_slice(line.as_bytes()),
1002 }
1003 }
1004 at += 1;
1005 }
1006 Ok(bytes)
1007}
1008
1009fn protection_fields(
1010 protection: &Protection,
1011 trunk: &str,
1012) -> Vec<(&'static str, toml_edit::Value)> {
1013 vec![
1014 (
1015 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
1016 protection.trunk_ruleset(trunk).into(),
1017 ),
1018 (
1019 "RK_CONFIG_PROTECTION_SAFETY_RULESET",
1020 protection.safety_ruleset(trunk).into(),
1021 ),
1022 (
1023 "RK_CONFIG_PROTECTION_TAG_RULESET",
1024 protection.tag_ruleset.clone().into(),
1025 ),
1026 (
1027 "RK_CONFIG_PROTECTION_LINES_RULESET",
1028 protection.lines_ruleset.clone().into(),
1029 ),
1030 (
1031 "RK_CONFIG_PROTECTION_TITLE_CHECK",
1032 protection.title_check.clone().into(),
1033 ),
1034 (
1035 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1036 protection.tag_pattern.clone().into(),
1037 ),
1038 (
1039 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
1040 array(&protection.bypass_actors),
1041 ),
1042 (
1043 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
1044 array(&protection.allowed_merge_methods),
1045 ),
1046 (
1047 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
1048 protection.strict_required_status_checks.into(),
1049 ),
1050 (
1051 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
1052 array(&protection.owned_trunk_rules),
1053 ),
1054 (
1055 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
1056 protection.required_approving_review_count.into(),
1057 ),
1058 (
1059 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
1060 protection.dismiss_stale_reviews_on_push.into(),
1061 ),
1062 (
1063 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
1064 protection.require_code_owner_review.into(),
1065 ),
1066 (
1067 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
1068 protection.require_last_push_approval.into(),
1069 ),
1070 (
1071 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
1072 protection.github.squash_title_source.clone().into(),
1073 ),
1074 (
1075 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
1076 protection.github.squash_body_source.clone().into(),
1077 ),
1078 (
1079 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
1080 protection.gitlab.merge_method.clone().into(),
1081 ),
1082 (
1083 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
1084 protection.gitlab.squash_option.clone().into(),
1085 ),
1086 (
1087 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
1088 protection.gitlab.squash_commit_template.clone().into(),
1089 ),
1090 (
1091 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
1092 protection.gitlab.push_access_level.into(),
1093 ),
1094 (
1095 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
1096 protection.gitlab.merge_access_level.into(),
1097 ),
1098 ]
1099}
1100
1101const DERIVED_POLICY_KEYS: [&str; 3] = [
1111 "protection.bypass_actors",
1112 "protection.owned_trunk_rules",
1113 "protection.gitlab.push_access_level",
1114];
1115
1116const PARAMETER_KEYS: [&str; 23] = [
1118 "project.repo",
1119 "profile.technologies",
1120 "profile.forge",
1121 "profile.release.mode",
1122 "profile.release.driver",
1123 "profile.release.style",
1124 "profile.release.line_prefix",
1125 "git.trunk",
1126 "git.checkout_mode",
1127 "git.integration",
1128 "protection.bypass_actors",
1132 "protection.owned_trunk_rules",
1133 "protection.gitlab.push_access_level",
1134 "capabilities.nix_packaging",
1135 "capabilities.reporting_policy",
1136 "capabilities.scorecard",
1137 "capabilities.code_scanning",
1138 "capabilities.workflow_guards",
1139 "security.contact",
1140 "security.response",
1141 "setup.required_check",
1144 "setup.required_workflow",
1145 "schema_version",
1146];
1147
1148pub fn with_acquisition(target: &Path, answer: &Acquisition) -> Result<(String, bool), RkError> {
1160 let path = target.join(CONFIG_PATH);
1161 let text = match std::fs::read_to_string(&path) {
1162 Ok(text) => text,
1163 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1164 Err(error) => return Err(error.into()),
1165 };
1166 if text.is_empty() {
1167 let template = crate::embedded::BLOCKS
1168 .get_file("acquisition-config.toml.in")
1169 .and_then(include_dir::File::contents_utf8)
1170 .ok_or_else(|| invalid("the binary lacks its acquisition template"))?;
1171 let mut fresh = template.as_bytes().to_vec();
1172 for (token, value) in [
1173 (
1174 "RK_CONFIG_SCHEMA_VERSION",
1175 toml_edit::Value::from(SCHEMA_VERSION),
1176 ),
1177 (
1178 "RK_CONFIG_ACQUISITION_SOURCE",
1179 toml_edit::Value::from(answer.source.as_str()),
1180 ),
1181 (
1182 "RK_CONFIG_ACQUISITION_MINIMUM",
1183 toml_edit::Value::from(answer.minimum.clone()),
1184 ),
1185 ] {
1186 fresh =
1187 crate::landing::substitute(&fresh, token.as_bytes(), value.to_string().as_bytes());
1188 }
1189 let fresh = String::from_utf8(fresh).map_err(|error| invalid(error.to_string()))?;
1190 parse(&fresh)?;
1191 return Ok((fresh, true));
1192 }
1193 let held = parse(&text)?;
1194 if held.acquisition.as_ref() == Some(answer) {
1195 return Ok((text, false));
1196 }
1197 let text = current_text(&text)?;
1198 let mut document = text
1199 .parse::<toml_edit::DocumentMut>()
1200 .map_err(|error| invalid(error.to_string()))?;
1201 if !document.contains_table("acquisition") {
1202 let mut table = toml_edit::Table::new();
1203 table.decor_mut().set_prefix("\n");
1204 document.insert("acquisition", toml_edit::Item::Table(table));
1205 }
1206 let table = document
1207 .get_mut("acquisition")
1208 .and_then(toml_edit::Item::as_table_mut)
1209 .ok_or_else(|| invalid("acquisition must be a table"))?;
1210 for (key, value, note) in [
1211 (
1212 "source",
1213 answer.source.as_str().to_owned(),
1214 " P: host where every host installs rk itself; absent where a manager file pins it",
1215 ),
1216 (
1217 "minimum",
1218 answer.minimum.clone(),
1219 " P: the lowest rk release a host may run against this project",
1220 ),
1221 ] {
1222 let kept = table
1223 .get(key)
1224 .and_then(toml_edit::Item::as_value)
1225 .map(|held| held.decor().clone());
1226 let mut value = toml_edit::Value::from(value);
1227 match kept {
1228 Some(decor) => *value.decor_mut() = decor,
1229 None => value.decor_mut().set_suffix(format!(" #{note}")),
1230 }
1231 table.insert(key, toml_edit::Item::Value(value));
1232 }
1233 let next = document.to_string();
1234 parse(&next)?;
1235 Ok((next, true))
1236}
1237
1238impl Config {
1239 #[must_use]
1242 pub fn states_only_a_migration(&self) -> bool {
1243 self.migration.is_some()
1244 && Self {
1245 migration: None,
1246 ..self.clone()
1247 } == Self::default()
1248 }
1249}
1250
1251pub fn with_migration(text: &str, id: Option<&str>) -> Result<String, RkError> {
1258 let mut document = text
1259 .parse::<toml_edit::DocumentMut>()
1260 .map_err(|error| invalid(error.to_string()))?;
1261 let root = document.as_table_mut();
1262 match id {
1263 Some(id) => {
1264 root.insert("migration", toml_edit::value(id));
1265 root.sort_values_by(|a, _, b, _| {
1267 let rank = |key: &toml_edit::Key| match key.get() {
1268 "schema_version" => 0,
1269 "migration" => 1,
1270 _ => 2,
1271 };
1272 rank(a).cmp(&rank(b))
1273 });
1274 }
1275 None => {
1276 root.remove("migration");
1277 }
1278 }
1279 let next = document.to_string();
1280 parse(&next)?;
1281 Ok(next)
1282}
1283
1284pub fn check_writable(target: &Path) -> Result<(), RkError> {
1294 let file = target.join(CONFIG_PATH);
1295 for (path, directory) in [(file.parent(), true), (Some(file.as_path()), false)] {
1296 let Some(path) = path else { continue };
1297 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1298 continue;
1299 };
1300 let kind = metadata.file_type();
1301 let fits = if directory {
1302 kind.is_dir()
1303 } else {
1304 kind.is_file()
1305 };
1306 if !fits {
1307 return Err(RkError::refusal(
1308 Diagnostic::new(
1309 Reason::StateDrift,
1310 format!(
1311 "{} is a symlink or not a regular {}, so a configuration write would not stay in the target",
1312 path.display(),
1313 if directory { "directory" } else { "file" }
1314 ),
1315 )
1316 .expected(format!("{CONFIG_PATH} as a regular file in a regular directory"))
1317 .action("replace the symlink with the directory or file it stands for")
1318 .target_state("unchanged"),
1319 ));
1320 }
1321 }
1322 Ok(())
1323}
1324
1325pub fn set_migration(target: &Path, id: Option<&str>) -> Result<(), RkError> {
1333 check_writable(target)?;
1334 let path = target.join(CONFIG_PATH);
1335 let text = match std::fs::read_to_string(&path) {
1336 Ok(text) => text,
1337 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1338 if id.is_none() {
1339 return Ok(());
1340 }
1341 format!("schema_version = {SCHEMA_VERSION}\n")
1342 }
1343 Err(error) => return Err(error.into()),
1344 };
1345 let next = with_migration(¤t_text(&text)?, id)?;
1346 write_checked(target, next.as_bytes())
1347}
1348
1349pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
1355 let path = target.join(CONFIG_PATH);
1356 let text = std::fs::read_to_string(&path)?;
1357 let next = rewrite_text(&text, key, Some(value))?;
1358 write_checked(target, next.as_bytes())
1359}
1360
1361fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
1364 rewrite_all(text, vec![(key, value)])
1365}
1366
1367fn rewrite_all(
1375 text: &str,
1376 values: Vec<(&str, Option<toml_edit::Value>)>,
1377) -> Result<String, RkError> {
1378 for (key, _) in &values {
1379 if !PARAMETER_KEYS.contains(key) {
1380 return Err(invalid(format!("{key} is not a landing parameter")));
1381 }
1382 }
1383 parse(text)?;
1384 let text = current_text(text)?;
1385 let mut document = text
1386 .parse::<toml_edit::DocumentMut>()
1387 .map_err(|error| invalid(error.to_string()))?;
1388 let mut changed = false;
1389 let mut emptied: Vec<&str> = Vec::new();
1392 for (key, value) in values {
1393 let removing = value.is_none();
1394 let applied = apply_key(&mut document, key, value)?;
1395 changed |= applied;
1396 if applied
1397 && removing
1398 && let Some(parent) = key.split('.').next()
1399 {
1400 emptied.push(parent);
1401 }
1402 }
1403 changed |= prune_empty_tables(&mut document, &emptied);
1404 changed |= migrate::refresh_comments(&mut document);
1411 if !changed {
1412 return Ok(text);
1413 }
1414 let next = document.to_string();
1415 parse(&next)?;
1416 Ok(next)
1417}
1418
1419pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1438 let order: Vec<String> = document
1439 .as_table()
1440 .iter()
1441 .map(|(key, _)| key.to_owned())
1442 .collect();
1443 let mut changed = false;
1444 for (index, name) in order.iter().enumerate() {
1445 if !names.contains(&name.as_str())
1446 || !document
1447 .get(name)
1448 .and_then(toml_edit::Item::as_table)
1449 .is_some_and(toml_edit::Table::is_empty)
1450 {
1451 continue;
1452 }
1453 let carried = document
1454 .get(name)
1455 .and_then(toml_edit::Item::as_table)
1456 .and_then(|table| carried_comment(table.decor()));
1457 document.remove(name);
1458 changed = true;
1459 let Some(carried) = carried else { continue };
1460 let next = order
1464 .iter()
1465 .skip(index + 1)
1466 .find(|name| {
1467 document
1468 .get(name)
1469 .and_then(toml_edit::Item::as_table)
1470 .is_some_and(|table| !table.is_implicit())
1471 })
1472 .cloned();
1473 if let Some(next) = next
1474 && let Some(table) = document
1475 .get_mut(&next)
1476 .and_then(toml_edit::Item::as_table_mut)
1477 {
1478 let existing = table
1479 .decor()
1480 .prefix()
1481 .and_then(toml_edit::RawString::as_str)
1482 .unwrap_or_default()
1483 .trim_start_matches('\n')
1484 .to_owned();
1485 table
1486 .decor_mut()
1487 .set_prefix(format!("\n{carried}{existing}"));
1488 } else {
1489 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1490 if !trailing.is_empty() && !trailing.ends_with('\n') {
1491 trailing.push('\n');
1492 }
1493 trailing.push_str(&carried);
1494 document.set_trailing(trailing);
1495 }
1496 }
1497 changed
1498}
1499
1500fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1507 let mut lines = String::new();
1508 for raw in [decor.prefix(), decor.suffix()] {
1509 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1510 continue;
1511 };
1512 for line in text
1513 .lines()
1514 .map(str::trim)
1515 .filter(|line| line.starts_with('#'))
1516 .filter(|line| !is_template_comment(line))
1517 {
1518 lines.push_str(line);
1519 lines.push('\n');
1520 }
1521 }
1522 (!lines.is_empty()).then_some(lines)
1523}
1524
1525fn is_template_comment(line: &str) -> bool {
1534 let rest = line.trim_start_matches('#').trim_start();
1535 ["P:", "N:", "F:"]
1536 .iter()
1537 .any(|marker| rest.starts_with(marker))
1538}
1539
1540pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1548 let first = document
1549 .as_table()
1550 .iter()
1551 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1552 .map(|(name, _)| name.to_owned());
1553 if let Some(first) = first
1554 && let Some(table) = document
1555 .get_mut(&first)
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 return;
1569 }
1570 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1571 if !trailing.is_empty() && !trailing.ends_with('\n') {
1572 trailing.push('\n');
1573 }
1574 trailing.push_str(carried);
1575 document.set_trailing(trailing);
1576}
1577
1578pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1584 let (name, _) = table.get_key_value(key)?;
1585 carried_comment(name.leaf_decor())
1586}
1587
1588pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1590 let Some(mut name) = table.key_mut(key) else {
1591 return;
1592 };
1593 let decor = name.leaf_decor_mut();
1594 let existing = decor
1595 .prefix()
1596 .and_then(toml_edit::RawString::as_str)
1597 .unwrap_or_default()
1598 .trim_start_matches('\n')
1599 .to_owned();
1600 decor.set_prefix(format!("\n{carried}{existing}"));
1601}
1602
1603#[must_use]
1605pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1606 known
1607 .iter()
1608 .min_by_key(|name| distance(unknown, name))
1609 .copied()
1610}
1611
1612pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1615 let carried = carried_comment(table.decor())?;
1616 table.decor_mut().set_prefix("\n");
1617 Some(carried)
1618}
1619
1620pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1631 let carried = table.get_key_value(key).and_then(|(name, item)| {
1632 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1633 if let Some(value) = item.as_value()
1634 && let Some(more) = carried_comment(value.decor())
1635 {
1636 lines.push_str(&more);
1637 }
1638 (!lines.is_empty()).then_some(lines)
1639 });
1640 let removed = table.remove(key).is_some();
1641 if let Some(carried) = carried {
1642 let existing = table
1643 .decor()
1644 .prefix()
1645 .and_then(toml_edit::RawString::as_str)
1646 .unwrap_or_default()
1647 .trim_start_matches('\n')
1648 .to_owned();
1649 table
1650 .decor_mut()
1651 .set_prefix(format!("\n{carried}{existing}"));
1652 }
1653 removed
1654}
1655
1656fn apply_key(
1659 document: &mut toml_edit::DocumentMut,
1660 key: &str,
1661 value: Option<toml_edit::Value>,
1662) -> Result<bool, RkError> {
1663 let segments: Vec<&str> = key.split('.').collect();
1664 let Some((last, parents)) = segments.split_last() else {
1667 return Err(invalid(format!("{key} names no key")));
1668 };
1669 let Some(mut value) = value else {
1670 let mut item = document.as_item_mut();
1671 for segment in parents {
1672 if item.get(segment).is_none() {
1673 return Ok(false);
1674 }
1675 item = &mut item[segment];
1676 }
1677 let removed = item
1678 .as_table_mut()
1679 .is_some_and(|table| take_comments(table, last));
1680 return Ok(removed);
1681 };
1682 let mut item = document.as_item_mut();
1683 for segment in parents {
1684 if item.get(segment).is_none() {
1685 let mut table = toml_edit::Table::new();
1686 table.set_implicit(true);
1687 item[segment] = toml_edit::Item::Table(table);
1688 }
1689 item = &mut item[segment];
1690 }
1691 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1692 if same_value(old, &value) {
1693 return Ok(false);
1694 }
1695 *value.decor_mut() = old.decor().clone();
1696 } else if let Some(comment) = migrate::template_comment(&segments) {
1697 value.decor_mut().set_suffix(comment);
1703 }
1704 item[last] = toml_edit::Item::Value(value);
1705 Ok(true)
1706}
1707
1708fn same_value(old: &toml_edit::Value, new: &toml_edit::Value) -> bool {
1712 let parsed = |value: &toml_edit::Value| {
1713 format!("v = {}", value.to_string().trim()).parse::<toml::Table>()
1714 };
1715 matches!((parsed(old), parsed(new)), (Ok(old), Ok(new)) if old == new)
1716}
1717
1718#[derive(Debug, Clone, serde::Serialize)]
1720pub struct Plan {
1721 pub action: &'static str,
1723 pub changes: Vec<String>,
1725 pub content: String,
1727}
1728
1729impl Plan {
1730 pub fn new(
1735 target: &Path,
1736 params: &crate::landing::Params,
1737 existing: Option<&Config>,
1738 record: Option<&crate::landing::manifest::Manifest>,
1739 ) -> Result<Self, RkError> {
1740 let text = existing
1741 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1742 .transpose()?;
1743 Self::compose(text.as_deref(), params, existing, record)
1744 }
1745
1746 pub fn compose(
1753 text: Option<&str>,
1754 params: &crate::landing::Params,
1755 existing: Option<&Config>,
1756 record: Option<&crate::landing::manifest::Manifest>,
1757 ) -> Result<Self, RkError> {
1758 let mut resolved = existing.cloned().unwrap_or_default();
1759 resolved.schema_version = SCHEMA_VERSION;
1760 params.repo().clone_into(&mut resolved.project.repo);
1761 resolved.profile = Profile {
1762 technologies: Some(params.technologies().to_vec()),
1763 forge: Some(params.forge().unwrap_or_default().to_owned()),
1764 release: Release {
1765 mode: Some(params.release_mode()),
1766 driver: params.driver().map(str::to_owned),
1767 style: params.style(),
1768 line_prefix: params.profile().release.line_prefix.clone(),
1769 },
1770 };
1771 resolved.git = Git {
1772 trunk: Some(params.trunk().to_owned()),
1773 checkout_mode: Some(params.checkout_mode()),
1774 integration: Some(params.integration()),
1775 };
1776 resolved.protection = protection_for(
1777 &resolved.protection,
1778 params.integration(),
1779 existing.is_none(),
1780 );
1781 resolved.capabilities = Capabilities {
1782 nix_packaging: Some(params.nix_packaging()),
1783 reporting_policy: Some(params.reporting_policy()),
1784 scorecard: Some(params.scorecard()),
1785 code_scanning: Some(
1786 params
1787 .code_scanning()
1788 .map_or("off", crate::landing::Provider::as_str)
1789 .to_owned(),
1790 ),
1791 workflow_guards: Some(params.workflow_guards()),
1792 };
1793 resolved.security.contact = Some(params.security_contact().to_owned());
1794 resolved.security.response = Some(params.security_response().to_owned());
1795 params
1796 .required_check()
1797 .clone_into(&mut resolved.setup.required_check);
1798 params
1799 .required_workflow()
1800 .clone_into(&mut resolved.setup.required_workflow);
1801 let bare = existing.is_some_and(Config::states_only_a_migration);
1806 let content = if let Some(text) = text.filter(|_| existing.is_some() && !bare) {
1807 rewrite_all(text, parameter_values(&resolved))?
1808 } else {
1809 let rendered =
1810 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?;
1811 with_migration(&rendered, resolved.migration.as_deref())?
1812 };
1813 parse(&content)?;
1814 Ok(Self {
1815 action: if existing.is_some() {
1816 "updated"
1817 } else {
1818 "added"
1819 },
1820 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1821 content,
1822 })
1823 }
1824
1825 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1831 write_checked(target, self.content.as_bytes())
1832 }
1833}
1834
1835fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1837 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1838 "project.repo",
1839 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1840 )];
1841 if let Some(list) = &config.profile.technologies {
1842 values.push(("profile.technologies", Some(array(list))));
1843 }
1844 if let Some(forge) = config.profile.forge.clone() {
1845 values.push(("profile.forge", Some(forge.into())));
1846 }
1847 if let Some(mode) = config.profile.release.mode {
1848 values.push(("profile.release.mode", Some(mode.as_str().into())));
1849 values.push((
1850 "profile.release.driver",
1851 config
1852 .profile
1853 .release
1854 .driver
1855 .clone()
1856 .map(toml_edit::Value::from),
1857 ));
1858 values.push((
1859 "profile.release.style",
1860 config
1861 .profile
1862 .release
1863 .style
1864 .map(|style| style.as_str().into()),
1865 ));
1866 values.push((
1867 "profile.release.line_prefix",
1868 config
1869 .profile
1870 .release
1871 .line_prefix
1872 .clone()
1873 .map(toml_edit::Value::from),
1874 ));
1875 }
1876 if let Some(trunk) = config.git.trunk.clone() {
1877 values.push(("git.trunk", Some(trunk.into())));
1878 }
1879 if let Some(mode) = config.git.checkout_mode {
1880 values.push(("git.checkout_mode", Some(mode.as_str().into())));
1881 }
1882 if let Some(mode) = config.git.integration {
1883 values.push(("git.integration", Some(mode.as_str().into())));
1884 let mut bypass = toml_edit::Array::new();
1888 for actor in &config.protection.bypass_actors {
1889 bypass.push(actor.as_str());
1890 }
1891 values.push((
1892 "protection.bypass_actors",
1893 Some(toml_edit::Value::Array(bypass)),
1894 ));
1895 let mut rules = toml_edit::Array::new();
1896 for rule in &config.protection.owned_trunk_rules {
1897 rules.push(rule.as_str());
1898 }
1899 values.push((
1900 "protection.owned_trunk_rules",
1901 Some(toml_edit::Value::Array(rules)),
1902 ));
1903 values.push((
1904 "protection.gitlab.push_access_level",
1905 Some(config.protection.gitlab.push_access_level.into()),
1906 ));
1907 }
1908 if let Some(value) = config.capabilities.nix_packaging {
1909 values.push(("capabilities.nix_packaging", Some(value.into())));
1910 }
1911 if let Some(value) = config.capabilities.reporting_policy {
1912 values.push(("capabilities.reporting_policy", Some(value.into())));
1913 }
1914 if let Some(value) = config.capabilities.scorecard {
1915 values.push(("capabilities.scorecard", Some(value.into())));
1916 }
1917 if let Some(value) = config.capabilities.code_scanning.clone() {
1918 values.push(("capabilities.code_scanning", Some(value.into())));
1919 }
1920 if let Some(value) = config.capabilities.workflow_guards {
1921 values.push(("capabilities.workflow_guards", Some(value.into())));
1922 }
1923 if let Some(value) = config.security.contact.clone() {
1926 values.push(("security.contact", Some(value.into())));
1927 }
1928 if let Some(value) = config.security.response.clone() {
1929 values.push(("security.response", Some(value.into())));
1930 }
1931 values.push((
1936 "setup.required_check",
1937 Some(config.setup.required_check.clone().into()),
1938 ));
1939 values.push((
1940 "setup.required_workflow",
1941 Some(config.setup.required_workflow.clone().into()),
1942 ));
1943 values
1944}
1945
1946#[must_use]
1948pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
1949 let recorded = {
1950 let params = crate::landing::Params::from_record(record);
1951 Plan::compose(None, ¶ms, None, None)
1952 .ok()
1953 .and_then(|plan| parse(&plan.content).ok())
1954 };
1955 let Some(recorded) = recorded else {
1956 return Vec::new();
1957 };
1958 let render = |value: &Option<toml_edit::Value>| {
1959 value.as_ref().map_or_else(
1960 || "<absent>".to_owned(),
1961 |value| value.to_string().trim().to_owned(),
1962 )
1963 };
1964 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
1965 .iter()
1966 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1967 .map(|(key, value)| (*key, render(value)))
1968 .collect();
1969 parameter_values(config)
1970 .into_iter()
1971 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1972 .map(|(key, value)| (key, render(&value)))
1973 .filter(|(key, value)| {
1974 baseline
1975 .iter()
1976 .any(|(other, old)| key == other && value != old)
1977 })
1978 .map(|(key, _)| key.to_owned())
1979 .collect()
1980}
1981
1982#[must_use]
1990pub(crate) fn local_protection() -> Protection {
1991 const MAINTAINER: i64 = 40;
1993 Protection {
1994 bypass_actors: vec![LOCAL_GITHUB_BYPASS.into()],
1995 gitlab: Gitlab {
1996 push_access_level: MAINTAINER,
1997 ..Gitlab::default()
1998 },
1999 ..Protection::default()
2000 }
2001}
2002
2003#[must_use]
2006pub(crate) fn legacy_local_protection(policy: &Protection) -> bool {
2007 policy.bypass_actors.is_empty()
2008 && policy.owned_trunk_rules == ["deletion", "non_fast_forward"]
2009 && policy.gitlab.push_access_level >= 40
2010}
2011
2012#[must_use]
2034fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
2035 let forge = Protection::default();
2036 let local = local_protection();
2037 let tuple = |policy: &Protection| {
2038 (
2039 policy.bypass_actors.clone(),
2040 policy.owned_trunk_rules.clone(),
2041 policy.gitlab.push_access_level,
2042 )
2043 };
2044 let taken = fresh
2045 || tuple(held) == tuple(&forge)
2046 || tuple(held) == tuple(&local)
2047 || legacy_local_protection(held);
2048 if !taken {
2049 return held.clone();
2050 }
2051 let mut next = held.clone();
2052 let source = match integration {
2053 Integration::Forge => forge,
2054 Integration::Local => local,
2055 };
2056 next.bypass_actors = source.bypass_actors;
2057 next.owned_trunk_rules = source.owned_trunk_rules;
2058 next.gitlab.push_access_level = source.gitlab.push_access_level;
2059 next
2060}
2061
2062pub fn trunk_of(target: &Path) -> Result<String, RkError> {
2067 Ok(load(target)?
2068 .and_then(|config| config.git.trunk)
2069 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
2070}
2071
2072pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
2077 Ok(load(target)?
2078 .and_then(|config| config.profile.release.line_prefix)
2079 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
2080}
2081
2082#[cfg(test)]
2083mod tests {
2084 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, set_migration, trunk_of, write};
2085 use crate::landing::{CheckoutMode, Integration, Style};
2086 use crate::profile::ReleaseMode;
2087
2088 fn resolved_defaults() -> Config {
2091 Config {
2092 project: super::Project {
2093 repo: "acme/widget".into(),
2094 },
2095 profile: super::Profile {
2096 technologies: Some(vec!["rust".into()]),
2097 forge: Some("github".into()),
2098 release: super::Release {
2099 mode: Some(ReleaseMode::Automatic),
2100 driver: Some("rust".into()),
2101 style: Some(Style::Trunk),
2102 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
2103 },
2104 },
2105 git: super::Git {
2106 trunk: Some(super::TRUNK_DEFAULT.into()),
2107 checkout_mode: Some(CheckoutMode::LinkedWorktree),
2108 integration: Some(Integration::Local),
2109 },
2110 capabilities: super::Capabilities {
2111 nix_packaging: Some(false),
2112 reporting_policy: Some(true),
2113 scorecard: Some(false),
2114 code_scanning: Some("off".to_owned()),
2115 workflow_guards: Some(false),
2116 },
2117 security: super::Security {
2120 contact: Some(String::new()),
2121 response: Some(super::RESPONSE_DEFAULT.into()),
2122 ..super::Security::default()
2123 },
2124 protection: super::Protection {
2127 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
2128 safety_ruleset: Some(format!("{}-safety", super::TRUNK_DEFAULT)),
2129 ..super::local_protection()
2130 },
2131 ..Config::default()
2132 }
2133 }
2134
2135 #[test]
2138 fn a_symlinked_configuration_path_refuses_every_writer() {
2139 let outside = tempfile::tempdir().expect("an outside directory exists");
2140 let text = "schema_version = 2\n";
2141 std::fs::write(outside.path().join("config.toml"), text).expect("writes");
2142 let linked_dir = tempfile::tempdir().expect("a tempdir");
2143 std::os::unix::fs::symlink(outside.path(), linked_dir.path().join(".release-kit"))
2144 .expect("links the directory");
2145 let linked_file = tempfile::tempdir().expect("a tempdir");
2146 std::fs::create_dir_all(linked_file.path().join(".release-kit")).expect("mkdir");
2147 std::os::unix::fs::symlink(
2148 outside.path().join("config.toml"),
2149 linked_file.path().join(CONFIG_PATH),
2150 )
2151 .expect("links the file");
2152 for dir in [&linked_dir, &linked_file] {
2153 let target = dir.path();
2154 let refusals = [
2155 write(target, &resolved_defaults()).expect_err("write refuses"),
2156 rewrite_key(target, "git.trunk", "main".into()).expect_err("rewrite refuses"),
2157 set_migration(target, Some("20261009T140205Z")).expect_err("the key refuses"),
2158 ];
2159 for error in refusals {
2160 assert_eq!(error.exit_code(), 73, "{error}");
2161 assert!(error.to_string().contains("symlink"), "{error}");
2162 }
2163 }
2164 assert_eq!(
2165 std::fs::read_to_string(outside.path().join("config.toml")).expect("reads"),
2166 text
2167 );
2168 }
2169
2170 #[test]
2171 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
2172 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
2173 let explicit = parse(
2174 "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",
2175 )
2176 .expect("explicit defaults parse");
2177 assert_eq!(omitted.git, super::Git::default());
2178 assert_eq!(omitted.capabilities, super::Capabilities::default());
2179 assert_eq!(
2180 explicit.git.checkout_mode,
2181 Some(CheckoutMode::LinkedWorktree)
2182 );
2183 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
2184 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
2185 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
2186 assert_eq!(explicit.capabilities.scorecard, Some(false));
2187 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
2188 assert_ne!(omitted, explicit);
2189 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
2191 .expect("the older spelling reads");
2192 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
2193 }
2194
2195 #[test]
2199 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
2200 let dir = tempfile::tempdir().expect("a tempdir");
2201 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
2202 std::fs::write(
2203 dir.path().join(CONFIG_PATH),
2204 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
2205 )
2206 .expect("the config writes");
2207 let held = load(dir.path())
2208 .expect("the config reads")
2209 .expect("it is present");
2210 assert_eq!(held.setup.bot.app_id, "123");
2211 assert_eq!(
2212 held.setup.bot.installation_id,
2213 Some(0),
2214 "the key parses; nothing reads it"
2215 );
2216 }
2217
2218 #[test]
2220 fn a_schema_1_config_migrates_into_its_domains() {
2221 let held = parse(
2222 "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",
2223 )
2224 .expect("a schema 1 file reads");
2225 assert_eq!(held.schema_version, 2);
2226 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
2227 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
2228 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
2229 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
2230 assert_eq!(held.profile.release.style, Some(Style::Lines));
2231 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
2232 assert_eq!(held.git.trunk.as_deref(), Some("main"));
2233 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
2234 assert_eq!(held.capabilities.nix_packaging, Some(false));
2235 assert_eq!(held.capabilities.reporting_policy, Some(true));
2236 }
2237
2238 #[test]
2240 fn every_invalid_release_state_names_its_key() {
2241 for (text, key) in [
2242 (
2243 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
2244 "profile.release.style",
2245 ),
2246 (
2247 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
2248 "profile.release.driver",
2249 ),
2250 (
2251 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
2252 "profile.release.line_prefix",
2253 ),
2254 (
2255 "[profile]\ntechnologies = ['rust', 'rust']\n",
2256 "profile.technologies",
2257 ),
2258 (
2259 "[profile]\ntechnologies = ['Rust!']\n",
2260 "profile.technologies",
2261 ),
2262 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
2263 (
2264 "[profile.release]\nmode = 'manual'\n",
2265 "profile.release.mode",
2266 ),
2267 ] {
2268 let error = parse(&format!("schema_version = 2\n{text}"))
2269 .expect_err("an invalid release state refuses")
2270 .to_string();
2271 assert!(error.contains(key), "{key}: {error}");
2272 }
2273 }
2274
2275 #[test]
2279 fn a_newly_written_key_takes_the_templates_comment() {
2280 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
2281 let next = super::rewrite_text(
2282 text,
2283 "git.integration",
2284 Some(toml_edit::Value::from("forge")),
2285 )
2286 .expect("the key writes");
2287 assert!(
2288 next.contains("integration = \"forge\" # P: local or forge"),
2289 "{next}"
2290 );
2291 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
2293 .expect("the key writes");
2294 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
2295 }
2296
2297 #[test]
2298 fn the_landed_config_template_round_trips() {
2299 let dir = tempfile::tempdir().expect("a target exists");
2300 let mut config = Config::default();
2301 config.project.repo = "acme/nested/widget".into();
2302 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
2303 config.profile.forge = Some("gitlab".into());
2304 config.profile.release = super::Release {
2305 mode: Some(ReleaseMode::Automatic),
2306 driver: Some("bash".into()),
2307 style: Some(Style::Lines),
2308 line_prefix: Some("stable/".into()),
2309 };
2310 config.git.trunk = Some("main".into());
2311 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
2312 config.git.integration = Some(Integration::Forge);
2313 config.capabilities.nix_packaging = Some(true);
2314 config.capabilities.reporting_policy = Some(false);
2315 config.capabilities.scorecard = Some(true);
2316 config.capabilities.code_scanning = Some("semgrep".to_owned());
2317 config.capabilities.workflow_guards = Some(true);
2318 config.security.advisories =
2322 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
2323 config.security.contact = Some("security team, room 3 \"the vault\"".into());
2324 config.security.response = Some("14 business days".into());
2325 config.setup.required_check = "build / test".into();
2326 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
2327 config.setup.release_lines = true;
2328 config.setup.excluded_steps = [
2329 (
2330 "package-check".to_owned(),
2331 "nothing is published".to_owned(),
2332 ),
2333 (
2334 "protect-trunk".to_owned(),
2335 "this project merges \"locally\"".to_owned(),
2336 ),
2337 ]
2338 .into_iter()
2339 .collect();
2340 config.setup.bot.app_id = "123".into();
2341 config.protection.trunk_ruleset = Some("primary".into());
2342 config.protection.safety_ruleset = Some("guardrail".into());
2343 config.protection.tag_ruleset = "versions".into();
2344 config.protection.lines_ruleset = "maintenance".into();
2345 config.protection.title_check = "intent".into();
2346 config.protection.tag_pattern = "refs/tags/*".into();
2347 config
2348 .protection
2349 .owned_trunk_rules
2350 .push("required_signatures".into());
2351 config.protection.required_approving_review_count = 2;
2352 config.protection.dismiss_stale_reviews_on_push = true;
2353 config.protection.require_code_owner_review = true;
2354 config.protection.require_last_push_approval = true;
2355 config.protection.gitlab.squash_commit_template =
2356 "%{title}\n\nContext: %{description}".into();
2357 config.protection.gitlab.merge_access_level = 40;
2358 let mut release_less = resolved_defaults();
2361 release_less.project.repo = String::new();
2362 release_less.profile.technologies = Some(Vec::new());
2363 release_less.profile.forge = Some(String::new());
2364 release_less.profile.release = super::Release {
2365 mode: Some(ReleaseMode::None),
2366 driver: None,
2367 style: None,
2368 line_prefix: None,
2369 };
2370 release_less.capabilities.reporting_policy = Some(false);
2371 for expected in [resolved_defaults(), config, release_less] {
2372 write(dir.path(), &expected).expect("the template renders");
2373 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
2374 let text =
2375 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2376 assert!(text.contains("# P: every technology"));
2377 assert!(text.contains("# F: invariant"));
2378 }
2379 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2380 assert!(
2381 !text.contains("style ="),
2382 "a none release writes no style: {text}"
2383 );
2384 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
2385 }
2386
2387 #[test]
2388 fn a_config_with_an_unknown_key_refuses_by_name() {
2389 for (table, typo, nearest) in [
2390 ("", "schemax_version", "schema_version"),
2391 ("git", "trunkx", "trunk"),
2392 ("profile.release", "stile", "style"),
2393 ("capabilities", "nix_packagingg", "nix_packaging"),
2394 ("security", "contactx", "contact"),
2395 ("setup", "required_checkx", "required_check"),
2396 ("setup.bot", "app_i", "app_id"),
2397 ("protection", "trunk_rulesett", "trunk_ruleset"),
2398 (
2399 "protection.github",
2400 "squash_body_sourcex",
2401 "squash_body_source",
2402 ),
2403 ("protection.gitlab", "squash_optionx", "squash_option"),
2404 ] {
2405 let header = if table.is_empty() {
2406 String::new()
2407 } else {
2408 format!("[{table}]\n")
2409 };
2410 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
2411 let error = parse(&text).expect_err("unknown keys refuse").to_string();
2412 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
2413 assert!(error.contains(expected), "{error}");
2414 }
2415 }
2416 }
2417
2418 #[test]
2422 fn an_exclusion_names_a_real_step_and_states_why() {
2423 for (text, expected) in [
2424 (
2425 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
2426 vec!["protect-trunkk", "nearest known step: protect-trunk"],
2427 ),
2428 (
2429 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
2430 vec!["protect-trunk", "no reason"],
2431 ),
2432 ] {
2433 let error = parse(&format!("schema_version = 2\n{text}"))
2434 .expect_err("the exclusion refuses")
2435 .to_string();
2436 for want in expected {
2437 assert!(error.contains(want), "{error}");
2438 }
2439 }
2440 let held = parse(
2441 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
2442 )
2443 .expect("a named step with a reason parses");
2444 assert_eq!(
2445 held.setup
2446 .excluded_steps
2447 .get("protect-trunk")
2448 .map(String::as_str),
2449 Some("we merge locally")
2450 );
2451 }
2452
2453 #[test]
2457 fn an_exclusion_does_not_lift_a_floor() {
2458 let error = parse(
2459 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
2460 )
2461 .expect_err("the floor binds an excluded step's keys too")
2462 .to_string();
2463 assert!(
2464 error.contains("protection.allowed_merge_methods"),
2465 "{error}"
2466 );
2467 }
2468
2469 #[test]
2470 fn a_config_at_an_unknown_schema_refuses() {
2471 for text in ["schema_version = 999", "schema_version = '2'", ""] {
2472 let error = parse(text)
2473 .expect_err("a schema must be declared and known")
2474 .to_string();
2475 assert!(
2476 error.contains(CONFIG_PATH) && error.contains("schema_version"),
2477 "{error}"
2478 );
2479 }
2480 }
2481
2482 #[test]
2483 fn an_unparsable_config_refuses_naming_the_position() {
2484 let error = parse("schema_version = 2\n[project\n")
2485 .expect_err("bad TOML refuses")
2486 .to_string();
2487 for expected in [CONFIG_PATH, "line 2", "column"] {
2488 assert!(error.contains(expected), "{error}");
2489 }
2490 }
2491
2492 #[test]
2493 fn an_absent_config_reads_as_none() {
2494 let dir = tempfile::tempdir().expect("a target exists");
2495 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2496 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2497 }
2498
2499 #[test]
2500 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2501 let dir = tempfile::tempdir().expect("a target exists");
2502 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2503 std::fs::write(
2504 dir.path().join(CONFIG_PATH),
2505 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2506 )
2507 .expect("a config exists");
2508 let error =
2509 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2510 assert_eq!(error.exit_code(), 73);
2511 assert!(
2512 error
2513 .to_string()
2514 .contains("protection.strict_required_status_checks")
2515 );
2516 }
2517
2518 #[test]
2521 fn an_equal_value_keeps_its_spelling() {
2522 let original = "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'tr\\u0075nk' # ours\n";
2523 let original = original.replace("'tr\\u0075nk'", "\"tr\\u0075nk\"");
2524 let text = super::rewrite_text(&original, "profile.release.style", Some("trunk".into()))
2525 .expect("the style rewrites");
2526 assert_eq!(text, original, "an equal value is rewritten");
2527 let quoted =
2528 "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk' # ours\n";
2529 let text = super::rewrite_text(quoted, "profile.release.style", Some("trunk".into()))
2530 .expect("the style rewrites");
2531 assert_eq!(
2532 text, quoted,
2533 "a literal string equal to the value is rewritten"
2534 );
2535 }
2536
2537 #[test]
2538 fn rewrite_key_preserves_comments() {
2539 let dir = tempfile::tempdir().expect("a target exists");
2540 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2541 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";
2542 let path = dir.path().join(CONFIG_PATH);
2543 std::fs::write(&path, original).expect("a config exists");
2544 rewrite_key(dir.path(), "profile.release.style", "lines".into())
2545 .expect("the style writes back");
2546 let text = std::fs::read_to_string(&path).expect("the text reads");
2547 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2548 assert_eq!(
2549 load(dir.path())
2550 .expect("the config reads")
2551 .expect("present")
2552 .profile
2553 .release
2554 .style,
2555 Some(Style::Lines)
2556 );
2557 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2558 .expect("an omitted table can be added");
2559 assert_eq!(
2560 load(dir.path())
2561 .expect("reads")
2562 .expect("present")
2563 .project
2564 .repo,
2565 "acme/widget"
2566 );
2567 rewrite_key(
2568 dir.path(),
2569 "security.contact",
2570 "security@acme.example".into(),
2571 )
2572 .expect("the contact is a landing parameter");
2573 rewrite_key(dir.path(), "security.response", "14 days".into())
2574 .expect("the response is a landing parameter");
2575 let held = load(dir.path()).expect("reads").expect("present");
2576 assert_eq!(
2577 held.security.contact.as_deref(),
2578 Some("security@acme.example")
2579 );
2580 assert_eq!(held.security.response.as_deref(), Some("14 days"));
2581 let text = std::fs::read_to_string(&path).expect("the text reads");
2582 assert!(text.contains("# keep me"), "the comment survives: {text}");
2583 let before = std::fs::read(&path).expect("the bytes read");
2584 for (key, value) in [
2585 ("security.advisories", "acme/private"),
2586 ("security.response", "90d"),
2587 ("profile.release.style", "unknown"),
2588 ] {
2589 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2590 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2591 }
2592 }
2593
2594 #[test]
2597 fn the_security_answers_are_held_to_their_grammar() {
2598 for value in ["team@acme.example", " https://acme.example/report ", ""] {
2599 super::canonical_contact(value).expect("a control-free line is a contact");
2600 }
2601 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2602 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2603 assert!(refusal.contains("security.contact"), "{refusal}");
2604 }
2605 assert_eq!(
2606 super::canonical_contact(" team@acme.example "),
2607 Ok("team@acme.example".to_owned()),
2608 "surrounding whitespace is trimmed"
2609 );
2610 for value in [
2611 "best-effort",
2612 "1 day",
2613 "2 days",
2614 "14 days",
2615 "1 business day",
2616 "14 business days",
2617 ] {
2618 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2619 }
2620 assert_eq!(
2621 super::canonical_response(""),
2622 Ok(super::RESPONSE_DEFAULT.to_owned()),
2623 "an empty answer reads as the compiled default"
2624 );
2625 for value in [
2626 "0 days",
2627 "1 days",
2628 "2 day",
2629 "+2 days",
2630 "02 days",
2631 "4294967296 days",
2632 "90d",
2633 "two days",
2634 "we answer quickly",
2635 "2 weeks",
2636 ] {
2637 let refusal =
2638 super::canonical_response(value).expect_err("an unstateable window refuses");
2639 assert!(refusal.contains("security.response"), "{value}: {refusal}");
2640 assert!(refusal.contains("business days"), "{value}: {refusal}");
2641 }
2642 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2643 .expect_err("the reader refuses it too")
2644 .to_string();
2645 assert!(refusal.contains("security.response"), "{refusal}");
2646 }
2647
2648 #[test]
2652 fn a_migration_key_that_is_no_identifier_refuses() {
2653 for id in ["20261009T140205Z", "20261009T140205Z-2"] {
2654 let config = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2655 .expect("an identifier parses");
2656 assert_eq!(config.migration.as_deref(), Some(id));
2657 }
2658 for id in [
2659 "../../elsewhere",
2660 "20261009T140205Z-1",
2661 "",
2662 "20261009T140205\u{e9}",
2663 "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
2664 ] {
2665 let refusal = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2666 .expect_err("a value that is no identifier refuses")
2667 .to_string();
2668 assert!(
2669 refusal.contains("no migration identifier"),
2670 "{id}: {refusal}"
2671 );
2672 }
2673 }
2674
2675 #[test]
2680 fn a_pruned_header_leaves_no_comment_behind() {
2681 let cases = [
2682 (
2683 "a middle table, comment above",
2684 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2685 ),
2686 (
2687 "a middle table, comment inline",
2688 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2689 ),
2690 (
2691 "the last table, comment above",
2692 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
2693 ),
2694 (
2695 "the last table, comment inline",
2696 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
2697 ),
2698 ];
2699 for (case, text) in cases {
2700 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2701 assert!(!next.contains("repo ="), "{case}: {next}");
2702 assert!(
2703 !next.contains("[project]"),
2704 "{case}: a table every one of whose keys dropped goes with them: {next}"
2705 );
2706 assert!(
2707 next.contains("# the operator's note"),
2708 "{case}: the comment the header carried survives: {next}"
2709 );
2710 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2711 }
2712 }
2713
2714 #[test]
2720 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2721 let text = concat!(
2722 "schema_version = 2\n\n[project]\n",
2723 "# the operator's note\n",
2724 "repo = \"acme/widget\" # P: project path on the forge\n\n",
2725 "[git]\ntrunk = \"main\"\n"
2726 );
2727 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2728 assert!(!next.contains("repo ="), "{next}");
2729 assert!(!next.contains("[project]"), "{next}");
2730 assert!(
2731 next.contains("# the operator's note"),
2732 "authored text survives: {next}"
2733 );
2734 assert!(
2735 !next.contains("# P:"),
2736 "the template's comment describes a key that is gone: {next}"
2737 );
2738 parse(&next).expect("the result parses");
2739
2740 let text = concat!(
2742 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2743 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2744 "# why this project names its own prefix\n",
2745 "line_prefix = \"stable/\"\n"
2746 );
2747 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2748 .expect("the key removes");
2749 assert!(!next.contains("line_prefix ="), "{next}");
2750 assert!(next.contains("[profile.release]"), "{next}");
2751 assert!(
2752 next.contains("# why this project names its own prefix"),
2753 "authored text survives: {next}"
2754 );
2755 }
2756}