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;
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 project: Project,
34 pub profile: Profile,
36 pub git: Git,
38 pub capabilities: Capabilities,
40 pub security: Security,
42 pub setup: Setup,
44 pub protection: Protection,
46}
47
48impl Default for Config {
49 fn default() -> Self {
50 Self {
51 schema_version: SCHEMA_VERSION,
52 project: Project::default(),
53 profile: Profile::default(),
54 git: Git::default(),
55 capabilities: Capabilities::default(),
56 security: Security::default(),
57 setup: Setup::default(),
58 protection: Protection::default(),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
65#[serde(deny_unknown_fields, default)]
66pub struct Project {
67 pub repo: String,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
74#[serde(deny_unknown_fields, default)]
75pub struct Profile {
76 pub technologies: Option<Vec<String>>,
78 pub forge: Option<String>,
81 pub release: Release,
83}
84
85#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
87#[serde(deny_unknown_fields, default)]
88pub struct Release {
89 pub mode: Option<ReleaseMode>,
92 pub driver: Option<String>,
95 pub style: Option<Style>,
97 pub line_prefix: Option<String>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
103#[serde(deny_unknown_fields, default)]
104pub struct Git {
105 pub trunk: Option<String>,
109 pub checkout_mode: Option<CheckoutMode>,
111 pub integration: Option<Integration>,
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
118#[serde(deny_unknown_fields, default)]
119pub struct Capabilities {
120 pub nix_packaging: Option<bool>,
122 pub reporting_policy: Option<bool>,
124 pub scorecard: Option<bool>,
126 pub code_scanning: Option<String>,
130}
131
132pub const TRUNK_DEFAULT: &str = "master";
134
135pub const LINE_PREFIX_DEFAULT: &str = "release/";
137
138#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
140#[serde(deny_unknown_fields, default)]
141pub struct Security {
142 pub advisories: String,
144 pub contact: Option<String>,
149 pub response: Option<String>,
152}
153
154pub const RESPONSE_DEFAULT: &str = "best-effort";
157
158pub fn canonical_contact(raw: &str) -> Result<String, String> {
168 let trimmed = raw.trim();
169 if trimmed.chars().any(char::is_control) {
170 return Err(format!(
171 "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:?}"
172 ));
173 }
174 Ok(trimmed.to_owned())
175}
176
177pub fn canonical_response(raw: &str) -> Result<String, String> {
189 let trimmed = raw.trim();
190 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
191 return Ok(RESPONSE_DEFAULT.to_owned());
192 }
193 let refusal = || {
194 format!(
195 "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:?}"
196 )
197 };
198 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
199 let plural = match unit {
200 "day" | "business day" => false,
201 "days" | "business days" => true,
202 _ => return Err(refusal()),
203 };
204 let number: u32 = count.parse().map_err(|_| refusal())?;
205 if count != number.to_string() || (number > 1) != plural {
208 return Err(refusal());
209 }
210 Ok(trimmed.to_owned())
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
215#[serde(deny_unknown_fields, default)]
216pub struct Setup {
217 pub required_check: String,
221 pub required_workflow: String,
225 pub retired_branches: Vec<String>,
227 pub release_lines: bool,
229 pub excluded_steps: BTreeMap<String, String>,
234 pub bot: Bot,
236}
237
238impl Default for Setup {
239 fn default() -> Self {
240 Self {
241 required_check: String::new(),
242 required_workflow: String::new(),
243 retired_branches: vec!["main".into(), "develop".into()],
244 release_lines: false,
245 excluded_steps: BTreeMap::new(),
246 bot: Bot::default(),
247 }
248 }
249}
250
251#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
258#[serde(deny_unknown_fields, default)]
259pub struct Bot {
260 pub app_id: String,
262 #[serde(default, skip_serializing)]
267 pub installation_id: Option<i64>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
272#[serde(deny_unknown_fields, default)]
273#[allow(
274 clippy::struct_excessive_bools,
275 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
276)]
277pub struct Protection {
278 pub trunk_ruleset: Option<String>,
282 pub tag_ruleset: String,
284 pub lines_ruleset: String,
286 pub title_check: String,
288 pub tag_pattern: String,
290 pub bypass_actors: Vec<String>,
292 pub allowed_merge_methods: Vec<String>,
294 pub strict_required_status_checks: bool,
296 pub owned_trunk_rules: Vec<String>,
300 pub required_approving_review_count: i64,
302 pub dismiss_stale_reviews_on_push: bool,
304 pub require_code_owner_review: bool,
306 pub require_last_push_approval: bool,
308 pub github: Github,
310 pub gitlab: Gitlab,
312}
313
314impl Default for Protection {
315 fn default() -> Self {
316 Self {
317 trunk_ruleset: None,
318 tag_ruleset: "release-tags".into(),
319 lines_ruleset: "release-lines".into(),
320 title_check: "pr-title".into(),
321 tag_pattern: "refs/tags/v*".into(),
322 bypass_actors: Vec::new(),
323 allowed_merge_methods: vec!["squash".into()],
324 strict_required_status_checks: true,
325 owned_trunk_rules: vec![
326 "deletion".into(),
327 "non_fast_forward".into(),
328 "pull_request".into(),
329 "required_status_checks".into(),
330 ],
331 required_approving_review_count: 0,
332 dismiss_stale_reviews_on_push: false,
333 require_code_owner_review: false,
334 require_last_push_approval: false,
335 github: Github::default(),
336 gitlab: Gitlab::default(),
337 }
338 }
339}
340
341impl Protection {
342 #[must_use]
345 pub fn trunk_ruleset(&self, trunk: &str) -> String {
346 self.trunk_ruleset
347 .clone()
348 .unwrap_or_else(|| format!("{trunk}-protection"))
349 }
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
354#[serde(deny_unknown_fields, default)]
355pub struct Github {
356 pub squash_title_source: String,
358 pub squash_body_source: String,
360}
361
362impl Default for Github {
363 fn default() -> Self {
364 Self {
365 squash_title_source: "PR_TITLE".into(),
366 squash_body_source: "PR_BODY".into(),
367 }
368 }
369}
370
371#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
373#[serde(deny_unknown_fields, default)]
374pub struct Gitlab {
375 pub merge_method: String,
377 pub squash_option: String,
379 pub squash_commit_template: String,
381 pub push_access_level: i64,
386 pub merge_access_level: i64,
388}
389
390impl Default for Gitlab {
391 fn default() -> Self {
392 Self {
393 merge_method: "ff".into(),
394 squash_option: "always".into(),
395 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
396 .trim_end_matches('\n')
397 .to_owned(),
398 push_access_level: 0,
399 merge_access_level: 40,
400 }
401 }
402}
403
404pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
409 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
410 Ok(text) => text,
411 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
412 Err(error) => return Err(error.into()),
413 };
414 parse(&text).map(Some)
415}
416
417fn current_text(text: &str) -> Result<String, RkError> {
420 let raw: toml::Value =
421 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
422 match raw.get("schema_version").and_then(toml::Value::as_integer) {
423 Some(1) => migrate::to_schema_2(text),
424 _ => Ok(text.to_owned()),
425 }
426}
427
428fn parse(text: &str) -> Result<Config, RkError> {
429 let text = current_text(text)?;
430 let raw: toml::Value =
431 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
432 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
433 return Err(invalid(format!(
434 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
435 )));
436 }
437 if let Some(mode) = raw
441 .get("profile")
442 .and_then(|profile| profile.get("release"))
443 .and_then(|release| release.get("mode"))
444 {
445 let named = mode.as_str().ok_or_else(|| {
446 invalid("profile.release.mode must be a string: automatic, external, or none")
447 })?;
448 crate::profile::ReleaseMode::parse(named)
449 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
450 }
451 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
452 let mut message = error.to_string();
453 if let Some(rest) = error.message().strip_prefix("unknown field `") {
454 let names: Vec<_> = rest.split('`').collect();
455 if let Some(unknown) = names.first()
456 && let Some(nearest) = names
457 .iter()
458 .skip(2)
459 .step_by(2)
460 .min_by_key(|name| distance(unknown, name))
461 {
462 let _ = write!(message, "; nearest known key: {nearest}");
463 }
464 }
465 invalid(message)
466 })?;
467 if let Some(technologies) = &config.profile.technologies {
468 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
469 }
470 if let Some(forge) = &config.profile.forge
471 && !forge.is_empty()
472 {
473 crate::profile::canonical_category(forge)
474 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
475 }
476 if let Some(driver) = &config.profile.release.driver {
477 crate::profile::canonical_category(driver)
478 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
479 }
480 if matches!(
481 config.profile.release.mode,
482 Some(ReleaseMode::External | ReleaseMode::None)
483 ) {
484 let mode = config
485 .profile
486 .release
487 .mode
488 .map_or("none", ReleaseMode::as_str);
489 for (key, present) in [
490 (
491 "profile.release.driver",
492 config.profile.release.driver.is_some(),
493 ),
494 (
495 "profile.release.style",
496 config.profile.release.style.is_some(),
497 ),
498 (
499 "profile.release.line_prefix",
500 config.profile.release.line_prefix.is_some(),
501 ),
502 ] {
503 if present {
504 return Err(invalid(format!(
505 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
506 )));
507 }
508 }
509 }
510 if let Some(contact) = &config.security.contact {
511 canonical_contact(contact).map_err(invalid)?;
512 }
513 if let Some(response) = &config.security.response {
514 canonical_response(response).map_err(invalid)?;
515 }
516 exclusions(&config.setup.excluded_steps)?;
517 floors::check(&config)?;
518 Ok(config)
519}
520
521fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
528 for (name, reason) in excluded {
529 if crate::setup::steps::spec(name).is_none() {
530 let nearest = crate::setup::steps::STEPS
531 .iter()
532 .min_by_key(|step| distance(name, step.name))
533 .map_or("", |step| step.name);
534 return Err(invalid(format!(
535 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
536 )));
537 }
538 if reason.trim().is_empty() {
539 return Err(invalid(format!(
540 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
541 )));
542 }
543 }
544 Ok(())
545}
546
547pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
548 RkError::refusal(
549 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
550 .action(format!("edit {CONFIG_PATH} and retry"))
551 .target_state("nothing was written"),
552 )
553}
554
555fn distance(left: &str, right: &str) -> usize {
556 let mut row: Vec<_> = (0..=right.chars().count()).collect();
557 for (i, a) in left.chars().enumerate() {
558 let mut previous = row[0];
559 row[0] = i + 1;
560 for (j, b) in right.chars().enumerate() {
561 let old = row[j + 1];
562 row[j + 1] = (previous + usize::from(a != b))
563 .min(row[j] + 1)
564 .min(old + 1);
565 previous = old;
566 }
567 }
568 row.last().copied().unwrap_or(0)
569}
570
571pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
576 let bytes = render(config)?;
577 parse(&String::from_utf8_lossy(&bytes))?;
578 crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
579 Ok(())
580}
581
582fn array(values: &[String]) -> toml_edit::Value {
583 toml_edit::Value::Array(values.iter().collect())
584}
585
586fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
591 let mut table = toml_edit::InlineTable::new();
592 for (key, value) in values {
593 table.insert(key, value.clone().into());
594 }
595 toml_edit::Value::InlineTable(table)
596}
597
598#[allow(
603 clippy::too_many_lines,
604 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
605)]
606fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
607 let trunk = config
611 .git
612 .trunk
613 .clone()
614 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
615 let technologies = config
616 .profile
617 .technologies
618 .clone()
619 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
620 let forge = config
621 .profile
622 .forge
623 .clone()
624 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
625 let mode = config
626 .profile
627 .release
628 .mode
629 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
630 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
631 (
632 "RK_CONFIG_SCHEMA_VERSION",
633 Some(config.schema_version.into()),
634 ),
635 (
636 "RK_CONFIG_PROJECT_REPO",
637 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
638 ),
639 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
640 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
641 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
642 (
643 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
644 config
645 .profile
646 .release
647 .driver
648 .clone()
649 .map(toml_edit::Value::from),
650 ),
651 (
652 "RK_CONFIG_PROFILE_RELEASE_STYLE",
653 config
654 .profile
655 .release
656 .style
657 .map(|style| style.as_str().into()),
658 ),
659 (
660 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
661 config
662 .profile
663 .release
664 .line_prefix
665 .clone()
666 .map(toml_edit::Value::from),
667 ),
668 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
669 (
670 "RK_CONFIG_GIT_CHECKOUT_MODE",
671 Some(
672 config
673 .git
674 .checkout_mode
675 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
676 .as_str()
677 .into(),
678 ),
679 ),
680 (
681 "RK_CONFIG_GIT_INTEGRATION",
682 Some(
683 config
684 .git
685 .integration
686 .ok_or_else(|| invalid("git.integration is unresolved"))?
687 .as_str()
688 .into(),
689 ),
690 ),
691 (
692 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
693 Some(
694 config
695 .capabilities
696 .nix_packaging
697 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
698 .into(),
699 ),
700 ),
701 (
702 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
703 Some(
704 config
705 .capabilities
706 .reporting_policy
707 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
708 .into(),
709 ),
710 ),
711 (
712 "RK_CONFIG_CAPABILITIES_SCORECARD",
713 Some(
714 config
715 .capabilities
716 .scorecard
717 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
718 .into(),
719 ),
720 ),
721 (
722 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
723 Some(
724 config
725 .capabilities
726 .code_scanning
727 .clone()
728 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
729 .into(),
730 ),
731 ),
732 (
733 "RK_CONFIG_SECURITY_ADVISORIES",
734 Some(config.security.advisories.clone().into()),
735 ),
736 (
737 "RK_CONFIG_SECURITY_CONTACT",
738 Some(config.security.contact.clone().unwrap_or_default().into()),
739 ),
740 (
741 "RK_CONFIG_SECURITY_RESPONSE",
742 Some(
743 config
744 .security
745 .response
746 .clone()
747 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
748 .into(),
749 ),
750 ),
751 (
752 "RK_CONFIG_SETUP_REQUIRED_CHECK",
753 Some(config.setup.required_check.clone().into()),
754 ),
755 (
756 "RK_CONFIG_SETUP_REQUIRED_WORKFLOW",
757 Some(config.setup.required_workflow.clone().into()),
758 ),
759 (
760 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
761 Some(array(&config.setup.retired_branches)),
762 ),
763 (
764 "RK_CONFIG_SETUP_RELEASE_LINES",
765 Some(config.setup.release_lines.into()),
766 ),
767 (
768 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
769 Some(inline(&config.setup.excluded_steps)),
770 ),
771 (
772 "RK_CONFIG_SETUP_BOT_APP_ID",
773 Some(config.setup.bot.app_id.clone().into()),
774 ),
775 ];
776 fields.extend(
777 protection_fields(&config.protection, trunk.as_str())
778 .into_iter()
779 .map(|(token, value)| (token, Some(value))),
780 );
781 Ok(fields)
782}
783
784fn render(config: &Config) -> Result<Vec<u8>, RkError> {
785 let fields = fields(config)?;
786 let template = crate::embedded::BLOCKS
787 .get_file("target-config.toml.in")
788 .and_then(include_dir::File::contents_utf8)
789 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
790 let lines: Vec<&str> = template.split_inclusive('\n').collect();
796 let dropped: Vec<bool> = lines
797 .iter()
798 .map(|line| {
799 matches!(
800 fields.iter().find(|(token, _)| line.contains(token)),
801 Some((_, None))
802 )
803 })
804 .collect();
805 let mut bytes = Vec::new();
806 let mut at = 0;
807 while at < lines.len() {
808 let line = lines[at];
809 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
810 let mut end = at + 1;
811 let mut keeps = false;
812 while end < lines.len()
813 && !(lines[end].trim_start().starts_with('[')
814 && lines[end].trim_end().ends_with(']'))
815 {
816 keeps |=
817 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
818 end += 1;
819 }
820 if !keeps {
821 while bytes.last() == Some(&b'\n')
823 && bytes.len() >= 2
824 && bytes[bytes.len() - 2] == b'\n'
825 {
826 bytes.pop();
827 }
828 at = end;
829 continue;
830 }
831 }
832 if !dropped[at] {
833 match fields.iter().find(|(token, _)| line.contains(token)) {
834 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
835 line.as_bytes(),
836 token.as_bytes(),
837 value.to_string().as_bytes(),
838 )),
839 Some((_, None)) => {}
840 None => bytes.extend_from_slice(line.as_bytes()),
841 }
842 }
843 at += 1;
844 }
845 Ok(bytes)
846}
847
848fn protection_fields(
849 protection: &Protection,
850 trunk: &str,
851) -> Vec<(&'static str, toml_edit::Value)> {
852 vec![
853 (
854 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
855 protection.trunk_ruleset(trunk).into(),
856 ),
857 (
858 "RK_CONFIG_PROTECTION_TAG_RULESET",
859 protection.tag_ruleset.clone().into(),
860 ),
861 (
862 "RK_CONFIG_PROTECTION_LINES_RULESET",
863 protection.lines_ruleset.clone().into(),
864 ),
865 (
866 "RK_CONFIG_PROTECTION_TITLE_CHECK",
867 protection.title_check.clone().into(),
868 ),
869 (
870 "RK_CONFIG_PROTECTION_TAG_PATTERN",
871 protection.tag_pattern.clone().into(),
872 ),
873 (
874 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
875 array(&protection.bypass_actors),
876 ),
877 (
878 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
879 array(&protection.allowed_merge_methods),
880 ),
881 (
882 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
883 protection.strict_required_status_checks.into(),
884 ),
885 (
886 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
887 array(&protection.owned_trunk_rules),
888 ),
889 (
890 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
891 protection.required_approving_review_count.into(),
892 ),
893 (
894 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
895 protection.dismiss_stale_reviews_on_push.into(),
896 ),
897 (
898 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
899 protection.require_code_owner_review.into(),
900 ),
901 (
902 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
903 protection.require_last_push_approval.into(),
904 ),
905 (
906 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
907 protection.github.squash_title_source.clone().into(),
908 ),
909 (
910 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
911 protection.github.squash_body_source.clone().into(),
912 ),
913 (
914 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
915 protection.gitlab.merge_method.clone().into(),
916 ),
917 (
918 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
919 protection.gitlab.squash_option.clone().into(),
920 ),
921 (
922 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
923 protection.gitlab.squash_commit_template.clone().into(),
924 ),
925 (
926 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
927 protection.gitlab.push_access_level.into(),
928 ),
929 (
930 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
931 protection.gitlab.merge_access_level.into(),
932 ),
933 ]
934}
935
936const DERIVED_POLICY_KEYS: [&str; 2] = [
946 "protection.owned_trunk_rules",
947 "protection.gitlab.push_access_level",
948];
949
950const PARAMETER_KEYS: [&str; 21] = [
952 "project.repo",
953 "profile.technologies",
954 "profile.forge",
955 "profile.release.mode",
956 "profile.release.driver",
957 "profile.release.style",
958 "profile.release.line_prefix",
959 "git.trunk",
960 "git.checkout_mode",
961 "git.integration",
962 "protection.owned_trunk_rules",
966 "protection.gitlab.push_access_level",
967 "capabilities.nix_packaging",
968 "capabilities.reporting_policy",
969 "capabilities.scorecard",
970 "capabilities.code_scanning",
971 "security.contact",
972 "security.response",
973 "setup.required_check",
976 "setup.required_workflow",
977 "schema_version",
978];
979
980pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
985 let path = target.join(CONFIG_PATH);
986 let text = std::fs::read_to_string(&path)?;
987 let next = rewrite_text(&text, key, Some(value))?;
988 crate::atomic::write(&path, next.as_bytes())?;
989 Ok(())
990}
991
992fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
995 rewrite_all(text, vec![(key, value)])
996}
997
998fn rewrite_all(
1006 text: &str,
1007 values: Vec<(&str, Option<toml_edit::Value>)>,
1008) -> Result<String, RkError> {
1009 for (key, _) in &values {
1010 if !PARAMETER_KEYS.contains(key) {
1011 return Err(invalid(format!("{key} is not a landing parameter")));
1012 }
1013 }
1014 parse(text)?;
1015 let text = current_text(text)?;
1016 let mut document = text
1017 .parse::<toml_edit::DocumentMut>()
1018 .map_err(|error| invalid(error.to_string()))?;
1019 let mut changed = false;
1020 let mut emptied: Vec<&str> = Vec::new();
1023 for (key, value) in values {
1024 let removing = value.is_none();
1025 let applied = apply_key(&mut document, key, value)?;
1026 changed |= applied;
1027 if applied
1028 && removing
1029 && let Some(parent) = key.split('.').next()
1030 {
1031 emptied.push(parent);
1032 }
1033 }
1034 changed |= prune_empty_tables(&mut document, &emptied);
1035 changed |= migrate::refresh_comments(&mut document);
1042 if !changed {
1043 return Ok(text);
1044 }
1045 let next = document.to_string();
1046 parse(&next)?;
1047 Ok(next)
1048}
1049
1050pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1069 let order: Vec<String> = document
1070 .as_table()
1071 .iter()
1072 .map(|(key, _)| key.to_owned())
1073 .collect();
1074 let mut changed = false;
1075 for (index, name) in order.iter().enumerate() {
1076 if !names.contains(&name.as_str())
1077 || !document
1078 .get(name)
1079 .and_then(toml_edit::Item::as_table)
1080 .is_some_and(toml_edit::Table::is_empty)
1081 {
1082 continue;
1083 }
1084 let carried = document
1085 .get(name)
1086 .and_then(toml_edit::Item::as_table)
1087 .and_then(|table| carried_comment(table.decor()));
1088 document.remove(name);
1089 changed = true;
1090 let Some(carried) = carried else { continue };
1091 let next = order
1095 .iter()
1096 .skip(index + 1)
1097 .find(|name| {
1098 document
1099 .get(name)
1100 .and_then(toml_edit::Item::as_table)
1101 .is_some_and(|table| !table.is_implicit())
1102 })
1103 .cloned();
1104 if let Some(next) = next
1105 && let Some(table) = document
1106 .get_mut(&next)
1107 .and_then(toml_edit::Item::as_table_mut)
1108 {
1109 let existing = table
1110 .decor()
1111 .prefix()
1112 .and_then(toml_edit::RawString::as_str)
1113 .unwrap_or_default()
1114 .trim_start_matches('\n')
1115 .to_owned();
1116 table
1117 .decor_mut()
1118 .set_prefix(format!("\n{carried}{existing}"));
1119 } else {
1120 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1121 if !trailing.is_empty() && !trailing.ends_with('\n') {
1122 trailing.push('\n');
1123 }
1124 trailing.push_str(&carried);
1125 document.set_trailing(trailing);
1126 }
1127 }
1128 changed
1129}
1130
1131fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1138 let mut lines = String::new();
1139 for raw in [decor.prefix(), decor.suffix()] {
1140 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1141 continue;
1142 };
1143 for line in text
1144 .lines()
1145 .map(str::trim)
1146 .filter(|line| line.starts_with('#'))
1147 .filter(|line| !is_template_comment(line))
1148 {
1149 lines.push_str(line);
1150 lines.push('\n');
1151 }
1152 }
1153 (!lines.is_empty()).then_some(lines)
1154}
1155
1156fn is_template_comment(line: &str) -> bool {
1165 let rest = line.trim_start_matches('#').trim_start();
1166 ["P:", "N:", "F:"]
1167 .iter()
1168 .any(|marker| rest.starts_with(marker))
1169}
1170
1171pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1179 let first = document
1180 .as_table()
1181 .iter()
1182 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1183 .map(|(name, _)| name.to_owned());
1184 if let Some(first) = first
1185 && let Some(table) = document
1186 .get_mut(&first)
1187 .and_then(toml_edit::Item::as_table_mut)
1188 {
1189 let existing = table
1190 .decor()
1191 .prefix()
1192 .and_then(toml_edit::RawString::as_str)
1193 .unwrap_or_default()
1194 .trim_start_matches('\n')
1195 .to_owned();
1196 table
1197 .decor_mut()
1198 .set_prefix(format!("\n{carried}{existing}"));
1199 return;
1200 }
1201 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1202 if !trailing.is_empty() && !trailing.ends_with('\n') {
1203 trailing.push('\n');
1204 }
1205 trailing.push_str(carried);
1206 document.set_trailing(trailing);
1207}
1208
1209pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1215 let (name, _) = table.get_key_value(key)?;
1216 carried_comment(name.leaf_decor())
1217}
1218
1219pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1221 let Some(mut name) = table.key_mut(key) else {
1222 return;
1223 };
1224 let decor = name.leaf_decor_mut();
1225 let existing = decor
1226 .prefix()
1227 .and_then(toml_edit::RawString::as_str)
1228 .unwrap_or_default()
1229 .trim_start_matches('\n')
1230 .to_owned();
1231 decor.set_prefix(format!("\n{carried}{existing}"));
1232}
1233
1234#[must_use]
1236pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1237 known
1238 .iter()
1239 .min_by_key(|name| distance(unknown, name))
1240 .copied()
1241}
1242
1243pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1246 let carried = carried_comment(table.decor())?;
1247 table.decor_mut().set_prefix("\n");
1248 Some(carried)
1249}
1250
1251pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1262 let carried = table.get_key_value(key).and_then(|(name, item)| {
1263 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1264 if let Some(value) = item.as_value()
1265 && let Some(more) = carried_comment(value.decor())
1266 {
1267 lines.push_str(&more);
1268 }
1269 (!lines.is_empty()).then_some(lines)
1270 });
1271 let removed = table.remove(key).is_some();
1272 if let Some(carried) = carried {
1273 let existing = table
1274 .decor()
1275 .prefix()
1276 .and_then(toml_edit::RawString::as_str)
1277 .unwrap_or_default()
1278 .trim_start_matches('\n')
1279 .to_owned();
1280 table
1281 .decor_mut()
1282 .set_prefix(format!("\n{carried}{existing}"));
1283 }
1284 removed
1285}
1286
1287fn apply_key(
1290 document: &mut toml_edit::DocumentMut,
1291 key: &str,
1292 value: Option<toml_edit::Value>,
1293) -> Result<bool, RkError> {
1294 let segments: Vec<&str> = key.split('.').collect();
1295 let Some((last, parents)) = segments.split_last() else {
1298 return Err(invalid(format!("{key} names no key")));
1299 };
1300 let Some(mut value) = value else {
1301 let mut item = document.as_item_mut();
1302 for segment in parents {
1303 if item.get(segment).is_none() {
1304 return Ok(false);
1305 }
1306 item = &mut item[segment];
1307 }
1308 let removed = item
1309 .as_table_mut()
1310 .is_some_and(|table| take_comments(table, last));
1311 return Ok(removed);
1312 };
1313 let mut item = document.as_item_mut();
1314 for segment in parents {
1315 if item.get(segment).is_none() {
1316 let mut table = toml_edit::Table::new();
1317 table.set_implicit(true);
1318 item[segment] = toml_edit::Item::Table(table);
1319 }
1320 item = &mut item[segment];
1321 }
1322 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1323 if old.to_string().trim() == value.to_string().trim() {
1324 return Ok(false);
1325 }
1326 *value.decor_mut() = old.decor().clone();
1327 } else if let Some(comment) = migrate::template_comment(&segments) {
1328 value.decor_mut().set_suffix(comment);
1334 }
1335 item[last] = toml_edit::Item::Value(value);
1336 Ok(true)
1337}
1338
1339#[derive(Debug, Clone, serde::Serialize)]
1341pub struct Plan {
1342 pub action: &'static str,
1344 pub changes: Vec<String>,
1346 pub content: String,
1348}
1349
1350impl Plan {
1351 pub fn new(
1356 target: &Path,
1357 params: &crate::landing::Params,
1358 existing: Option<&Config>,
1359 record: Option<&crate::landing::manifest::Manifest>,
1360 ) -> Result<Self, RkError> {
1361 let text = existing
1362 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1363 .transpose()?;
1364 Self::compose(text.as_deref(), params, existing, record)
1365 }
1366
1367 pub fn compose(
1374 text: Option<&str>,
1375 params: &crate::landing::Params,
1376 existing: Option<&Config>,
1377 record: Option<&crate::landing::manifest::Manifest>,
1378 ) -> Result<Self, RkError> {
1379 let mut resolved = existing.cloned().unwrap_or_default();
1380 resolved.schema_version = SCHEMA_VERSION;
1381 params.repo().clone_into(&mut resolved.project.repo);
1382 resolved.profile = Profile {
1383 technologies: Some(params.technologies().to_vec()),
1384 forge: Some(params.forge().unwrap_or_default().to_owned()),
1385 release: Release {
1386 mode: Some(params.release_mode()),
1387 driver: params.driver().map(str::to_owned),
1388 style: params.style(),
1389 line_prefix: params.profile().release.line_prefix.clone(),
1390 },
1391 };
1392 resolved.git = Git {
1393 trunk: Some(params.trunk().to_owned()),
1394 checkout_mode: Some(params.checkout_mode()),
1395 integration: Some(params.integration()),
1396 };
1397 resolved.protection = protection_for(
1398 &resolved.protection,
1399 params.integration(),
1400 existing.is_none(),
1401 );
1402 resolved.capabilities = Capabilities {
1403 nix_packaging: Some(params.nix_packaging()),
1404 reporting_policy: Some(params.reporting_policy()),
1405 scorecard: Some(params.scorecard()),
1406 code_scanning: Some(
1407 params
1408 .code_scanning()
1409 .map_or("off", crate::landing::Provider::as_str)
1410 .to_owned(),
1411 ),
1412 };
1413 resolved.security.contact = Some(params.security_contact().to_owned());
1414 resolved.security.response = Some(params.security_response().to_owned());
1415 params
1416 .required_check()
1417 .clone_into(&mut resolved.setup.required_check);
1418 params
1419 .required_workflow()
1420 .clone_into(&mut resolved.setup.required_workflow);
1421 let content = if let Some(text) = text.filter(|_| existing.is_some()) {
1422 rewrite_all(text, parameter_values(&resolved))?
1423 } else {
1424 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
1425 };
1426 parse(&content)?;
1427 Ok(Self {
1428 action: if existing.is_some() {
1429 "updated"
1430 } else {
1431 "added"
1432 },
1433 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1434 content,
1435 })
1436 }
1437
1438 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1443 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
1444 Ok(())
1445 }
1446}
1447
1448fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1450 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1451 "project.repo",
1452 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1453 )];
1454 if let Some(list) = &config.profile.technologies {
1455 values.push(("profile.technologies", Some(array(list))));
1456 }
1457 if let Some(forge) = config.profile.forge.clone() {
1458 values.push(("profile.forge", Some(forge.into())));
1459 }
1460 if let Some(mode) = config.profile.release.mode {
1461 values.push(("profile.release.mode", Some(mode.as_str().into())));
1462 values.push((
1463 "profile.release.driver",
1464 config
1465 .profile
1466 .release
1467 .driver
1468 .clone()
1469 .map(toml_edit::Value::from),
1470 ));
1471 values.push((
1472 "profile.release.style",
1473 config
1474 .profile
1475 .release
1476 .style
1477 .map(|style| style.as_str().into()),
1478 ));
1479 values.push((
1480 "profile.release.line_prefix",
1481 config
1482 .profile
1483 .release
1484 .line_prefix
1485 .clone()
1486 .map(toml_edit::Value::from),
1487 ));
1488 }
1489 if let Some(trunk) = config.git.trunk.clone() {
1490 values.push(("git.trunk", Some(trunk.into())));
1491 }
1492 if let Some(mode) = config.git.checkout_mode {
1493 values.push(("git.checkout_mode", Some(mode.as_str().into())));
1494 }
1495 if let Some(mode) = config.git.integration {
1496 values.push(("git.integration", Some(mode.as_str().into())));
1497 let mut rules = toml_edit::Array::new();
1501 for rule in &config.protection.owned_trunk_rules {
1502 rules.push(rule.as_str());
1503 }
1504 values.push((
1505 "protection.owned_trunk_rules",
1506 Some(toml_edit::Value::Array(rules)),
1507 ));
1508 values.push((
1509 "protection.gitlab.push_access_level",
1510 Some(config.protection.gitlab.push_access_level.into()),
1511 ));
1512 }
1513 if let Some(value) = config.capabilities.nix_packaging {
1514 values.push(("capabilities.nix_packaging", Some(value.into())));
1515 }
1516 if let Some(value) = config.capabilities.reporting_policy {
1517 values.push(("capabilities.reporting_policy", Some(value.into())));
1518 }
1519 if let Some(value) = config.capabilities.scorecard {
1520 values.push(("capabilities.scorecard", Some(value.into())));
1521 }
1522 if let Some(value) = config.capabilities.code_scanning.clone() {
1523 values.push(("capabilities.code_scanning", Some(value.into())));
1524 }
1525 if let Some(value) = config.security.contact.clone() {
1528 values.push(("security.contact", Some(value.into())));
1529 }
1530 if let Some(value) = config.security.response.clone() {
1531 values.push(("security.response", Some(value.into())));
1532 }
1533 values.push((
1538 "setup.required_check",
1539 Some(config.setup.required_check.clone().into()),
1540 ));
1541 values.push((
1542 "setup.required_workflow",
1543 Some(config.setup.required_workflow.clone().into()),
1544 ));
1545 values
1546}
1547
1548#[must_use]
1550pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
1551 let recorded = {
1552 let params = crate::landing::Params::from_record(record);
1553 Plan::compose(None, ¶ms, None, None)
1554 .ok()
1555 .and_then(|plan| parse(&plan.content).ok())
1556 };
1557 let Some(recorded) = recorded else {
1558 return Vec::new();
1559 };
1560 let render = |value: &Option<toml_edit::Value>| {
1561 value.as_ref().map_or_else(
1562 || "<absent>".to_owned(),
1563 |value| value.to_string().trim().to_owned(),
1564 )
1565 };
1566 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
1567 .iter()
1568 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1569 .map(|(key, value)| (*key, render(value)))
1570 .collect();
1571 parameter_values(config)
1572 .into_iter()
1573 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1574 .map(|(key, value)| (key, render(&value)))
1575 .filter(|(key, value)| {
1576 baseline
1577 .iter()
1578 .any(|(other, old)| key == other && value != old)
1579 })
1580 .map(|(key, _)| key.to_owned())
1581 .collect()
1582}
1583
1584#[must_use]
1592fn local_protection() -> Protection {
1593 const MAINTAINER: i64 = 40;
1595 let mut policy = Protection::default();
1596 policy
1597 .owned_trunk_rules
1598 .retain(|rule| rule != "pull_request" && rule != "required_status_checks");
1599 policy.gitlab.push_access_level = MAINTAINER;
1600 policy
1601}
1602
1603#[must_use]
1624fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
1625 let forge = Protection::default();
1626 let local = local_protection();
1627 let pair = |policy: &Protection| {
1628 (
1629 policy.owned_trunk_rules.clone(),
1630 policy.gitlab.push_access_level,
1631 )
1632 };
1633 let taken = fresh || pair(held) == pair(&forge) || pair(held) == pair(&local);
1634 if !taken {
1635 return held.clone();
1636 }
1637 let mut next = held.clone();
1638 let source = match integration {
1639 Integration::Forge => forge,
1640 Integration::Local => local,
1641 };
1642 next.owned_trunk_rules = source.owned_trunk_rules;
1643 next.gitlab.push_access_level = source.gitlab.push_access_level;
1644 next
1645}
1646
1647pub fn trunk_of(target: &Path) -> Result<String, RkError> {
1652 Ok(load(target)?
1653 .and_then(|config| config.git.trunk)
1654 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
1655}
1656
1657pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
1662 Ok(load(target)?
1663 .and_then(|config| config.profile.release.line_prefix)
1664 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
1665}
1666
1667#[cfg(test)]
1668mod tests {
1669 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
1670 use crate::landing::{CheckoutMode, Integration, Style};
1671 use crate::profile::ReleaseMode;
1672
1673 fn resolved_defaults() -> Config {
1676 Config {
1677 project: super::Project {
1678 repo: "acme/widget".into(),
1679 },
1680 profile: super::Profile {
1681 technologies: Some(vec!["rust".into()]),
1682 forge: Some("github".into()),
1683 release: super::Release {
1684 mode: Some(ReleaseMode::Automatic),
1685 driver: Some("rust".into()),
1686 style: Some(Style::Trunk),
1687 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
1688 },
1689 },
1690 git: super::Git {
1691 trunk: Some(super::TRUNK_DEFAULT.into()),
1692 checkout_mode: Some(CheckoutMode::LinkedWorktree),
1693 integration: Some(Integration::Local),
1694 },
1695 capabilities: super::Capabilities {
1696 nix_packaging: Some(false),
1697 reporting_policy: Some(true),
1698 scorecard: Some(false),
1699 code_scanning: Some("off".to_owned()),
1700 },
1701 security: super::Security {
1704 contact: Some(String::new()),
1705 response: Some(super::RESPONSE_DEFAULT.into()),
1706 ..super::Security::default()
1707 },
1708 protection: super::Protection {
1711 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
1712 ..super::Protection::default()
1713 },
1714 ..Config::default()
1715 }
1716 }
1717
1718 #[test]
1719 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
1720 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
1721 let explicit = parse(
1722 "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",
1723 )
1724 .expect("explicit defaults parse");
1725 assert_eq!(omitted.git, super::Git::default());
1726 assert_eq!(omitted.capabilities, super::Capabilities::default());
1727 assert_eq!(
1728 explicit.git.checkout_mode,
1729 Some(CheckoutMode::LinkedWorktree)
1730 );
1731 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
1732 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
1733 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
1734 assert_eq!(explicit.capabilities.scorecard, Some(false));
1735 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
1736 assert_ne!(omitted, explicit);
1737 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
1739 .expect("the older spelling reads");
1740 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
1741 }
1742
1743 #[test]
1747 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
1748 let dir = tempfile::tempdir().expect("a tempdir");
1749 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
1750 std::fs::write(
1751 dir.path().join(CONFIG_PATH),
1752 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
1753 )
1754 .expect("the config writes");
1755 let held = load(dir.path())
1756 .expect("the config reads")
1757 .expect("it is present");
1758 assert_eq!(held.setup.bot.app_id, "123");
1759 assert_eq!(
1760 held.setup.bot.installation_id,
1761 Some(0),
1762 "the key parses; nothing reads it"
1763 );
1764 }
1765
1766 #[test]
1768 fn a_schema_1_config_migrates_into_its_domains() {
1769 let held = parse(
1770 "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",
1771 )
1772 .expect("a schema 1 file reads");
1773 assert_eq!(held.schema_version, 2);
1774 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
1775 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
1776 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
1777 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
1778 assert_eq!(held.profile.release.style, Some(Style::Lines));
1779 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
1780 assert_eq!(held.git.trunk.as_deref(), Some("main"));
1781 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
1782 assert_eq!(held.capabilities.nix_packaging, Some(false));
1783 assert_eq!(held.capabilities.reporting_policy, Some(true));
1784 }
1785
1786 #[test]
1788 fn every_invalid_release_state_names_its_key() {
1789 for (text, key) in [
1790 (
1791 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
1792 "profile.release.style",
1793 ),
1794 (
1795 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
1796 "profile.release.driver",
1797 ),
1798 (
1799 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
1800 "profile.release.line_prefix",
1801 ),
1802 (
1803 "[profile]\ntechnologies = ['rust', 'rust']\n",
1804 "profile.technologies",
1805 ),
1806 (
1807 "[profile]\ntechnologies = ['Rust!']\n",
1808 "profile.technologies",
1809 ),
1810 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
1811 (
1812 "[profile.release]\nmode = 'manual'\n",
1813 "profile.release.mode",
1814 ),
1815 ] {
1816 let error = parse(&format!("schema_version = 2\n{text}"))
1817 .expect_err("an invalid release state refuses")
1818 .to_string();
1819 assert!(error.contains(key), "{key}: {error}");
1820 }
1821 }
1822
1823 #[test]
1827 fn a_newly_written_key_takes_the_templates_comment() {
1828 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
1829 let next = super::rewrite_text(
1830 text,
1831 "git.integration",
1832 Some(toml_edit::Value::from("local")),
1833 )
1834 .expect("the key writes");
1835 assert!(
1836 next.contains("integration = \"local\" # P: local or forge"),
1837 "{next}"
1838 );
1839 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
1841 .expect("the key writes");
1842 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
1843 }
1844
1845 #[test]
1846 fn the_landed_config_template_round_trips() {
1847 let dir = tempfile::tempdir().expect("a target exists");
1848 let mut config = Config::default();
1849 config.project.repo = "acme/nested/widget".into();
1850 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
1851 config.profile.forge = Some("gitlab".into());
1852 config.profile.release = super::Release {
1853 mode: Some(ReleaseMode::Automatic),
1854 driver: Some("bash".into()),
1855 style: Some(Style::Lines),
1856 line_prefix: Some("stable/".into()),
1857 };
1858 config.git.trunk = Some("main".into());
1859 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
1860 config.git.integration = Some(Integration::Forge);
1861 config.capabilities.nix_packaging = Some(true);
1862 config.capabilities.reporting_policy = Some(false);
1863 config.capabilities.scorecard = Some(true);
1864 config.capabilities.code_scanning = Some("semgrep".to_owned());
1865 config.security.advisories =
1869 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
1870 config.security.contact = Some("security team, room 3 \"the vault\"".into());
1871 config.security.response = Some("14 business days".into());
1872 config.setup.required_check = "build / test".into();
1873 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
1874 config.setup.release_lines = true;
1875 config.setup.excluded_steps = [
1876 (
1877 "package-check".to_owned(),
1878 "nothing is published".to_owned(),
1879 ),
1880 (
1881 "protect-trunk".to_owned(),
1882 "this project merges \"locally\"".to_owned(),
1883 ),
1884 ]
1885 .into_iter()
1886 .collect();
1887 config.setup.bot.app_id = "123".into();
1888 config.protection.trunk_ruleset = Some("primary".into());
1889 config.protection.tag_ruleset = "versions".into();
1890 config.protection.lines_ruleset = "maintenance".into();
1891 config.protection.title_check = "intent".into();
1892 config.protection.tag_pattern = "refs/tags/*".into();
1893 config
1894 .protection
1895 .owned_trunk_rules
1896 .push("required_signatures".into());
1897 config.protection.required_approving_review_count = 2;
1898 config.protection.dismiss_stale_reviews_on_push = true;
1899 config.protection.require_code_owner_review = true;
1900 config.protection.require_last_push_approval = true;
1901 config.protection.gitlab.squash_commit_template =
1902 "%{title}\n\nContext: %{description}".into();
1903 config.protection.gitlab.merge_access_level = 40;
1904 let mut release_less = resolved_defaults();
1907 release_less.project.repo = String::new();
1908 release_less.profile.technologies = Some(Vec::new());
1909 release_less.profile.forge = Some(String::new());
1910 release_less.profile.release = super::Release {
1911 mode: Some(ReleaseMode::None),
1912 driver: None,
1913 style: None,
1914 line_prefix: None,
1915 };
1916 release_less.capabilities.reporting_policy = Some(false);
1917 for expected in [resolved_defaults(), config, release_less] {
1918 write(dir.path(), &expected).expect("the template renders");
1919 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
1920 let text =
1921 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1922 assert!(text.contains("# P: every technology"));
1923 assert!(text.contains("# F: invariant"));
1924 }
1925 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1926 assert!(
1927 !text.contains("style ="),
1928 "a none release writes no style: {text}"
1929 );
1930 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
1931 }
1932
1933 #[test]
1934 fn a_config_with_an_unknown_key_refuses_by_name() {
1935 for (table, typo, nearest) in [
1936 ("", "schemax_version", "schema_version"),
1937 ("git", "trunkx", "trunk"),
1938 ("profile.release", "stile", "style"),
1939 ("capabilities", "nix_packagingg", "nix_packaging"),
1940 ("security", "contactx", "contact"),
1941 ("setup", "required_checkx", "required_check"),
1942 ("setup.bot", "app_i", "app_id"),
1943 ("protection", "trunk_rulesett", "trunk_ruleset"),
1944 (
1945 "protection.github",
1946 "squash_body_sourcex",
1947 "squash_body_source",
1948 ),
1949 ("protection.gitlab", "squash_optionx", "squash_option"),
1950 ] {
1951 let header = if table.is_empty() {
1952 String::new()
1953 } else {
1954 format!("[{table}]\n")
1955 };
1956 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
1957 let error = parse(&text).expect_err("unknown keys refuse").to_string();
1958 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
1959 assert!(error.contains(expected), "{error}");
1960 }
1961 }
1962 }
1963
1964 #[test]
1968 fn an_exclusion_names_a_real_step_and_states_why() {
1969 for (text, expected) in [
1970 (
1971 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
1972 vec!["protect-trunkk", "nearest known step: protect-trunk"],
1973 ),
1974 (
1975 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
1976 vec!["protect-trunk", "no reason"],
1977 ),
1978 ] {
1979 let error = parse(&format!("schema_version = 2\n{text}"))
1980 .expect_err("the exclusion refuses")
1981 .to_string();
1982 for want in expected {
1983 assert!(error.contains(want), "{error}");
1984 }
1985 }
1986 let held = parse(
1987 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
1988 )
1989 .expect("a named step with a reason parses");
1990 assert_eq!(
1991 held.setup
1992 .excluded_steps
1993 .get("protect-trunk")
1994 .map(String::as_str),
1995 Some("we merge locally")
1996 );
1997 }
1998
1999 #[test]
2003 fn an_exclusion_does_not_lift_a_floor() {
2004 let error = parse(
2005 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
2006 )
2007 .expect_err("the floor binds an excluded step's keys too")
2008 .to_string();
2009 assert!(
2010 error.contains("protection.allowed_merge_methods"),
2011 "{error}"
2012 );
2013 }
2014
2015 #[test]
2016 fn a_config_at_an_unknown_schema_refuses() {
2017 for text in ["schema_version = 999", "schema_version = '2'", ""] {
2018 let error = parse(text)
2019 .expect_err("a schema must be declared and known")
2020 .to_string();
2021 assert!(
2022 error.contains(CONFIG_PATH) && error.contains("schema_version"),
2023 "{error}"
2024 );
2025 }
2026 }
2027
2028 #[test]
2029 fn an_unparsable_config_refuses_naming_the_position() {
2030 let error = parse("schema_version = 2\n[project\n")
2031 .expect_err("bad TOML refuses")
2032 .to_string();
2033 for expected in [CONFIG_PATH, "line 2", "column"] {
2034 assert!(error.contains(expected), "{error}");
2035 }
2036 }
2037
2038 #[test]
2039 fn an_absent_config_reads_as_none() {
2040 let dir = tempfile::tempdir().expect("a target exists");
2041 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2042 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2043 }
2044
2045 #[test]
2046 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2047 let dir = tempfile::tempdir().expect("a target exists");
2048 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2049 std::fs::write(
2050 dir.path().join(CONFIG_PATH),
2051 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2052 )
2053 .expect("a config exists");
2054 let error =
2055 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2056 assert_eq!(error.exit_code(), 73);
2057 assert!(
2058 error
2059 .to_string()
2060 .contains("protection.strict_required_status_checks")
2061 );
2062 }
2063
2064 #[test]
2065 fn rewrite_key_preserves_comments() {
2066 let dir = tempfile::tempdir().expect("a target exists");
2067 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2068 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";
2069 let path = dir.path().join(CONFIG_PATH);
2070 std::fs::write(&path, original).expect("a config exists");
2071 rewrite_key(dir.path(), "profile.release.style", "lines".into())
2072 .expect("the style writes back");
2073 let text = std::fs::read_to_string(&path).expect("the text reads");
2074 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2075 assert_eq!(
2076 load(dir.path())
2077 .expect("the config reads")
2078 .expect("present")
2079 .profile
2080 .release
2081 .style,
2082 Some(Style::Lines)
2083 );
2084 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2085 .expect("an omitted table can be added");
2086 assert_eq!(
2087 load(dir.path())
2088 .expect("reads")
2089 .expect("present")
2090 .project
2091 .repo,
2092 "acme/widget"
2093 );
2094 rewrite_key(
2095 dir.path(),
2096 "security.contact",
2097 "security@acme.example".into(),
2098 )
2099 .expect("the contact is a landing parameter");
2100 rewrite_key(dir.path(), "security.response", "14 days".into())
2101 .expect("the response is a landing parameter");
2102 let held = load(dir.path()).expect("reads").expect("present");
2103 assert_eq!(
2104 held.security.contact.as_deref(),
2105 Some("security@acme.example")
2106 );
2107 assert_eq!(held.security.response.as_deref(), Some("14 days"));
2108 let text = std::fs::read_to_string(&path).expect("the text reads");
2109 assert!(text.contains("# keep me"), "the comment survives: {text}");
2110 let before = std::fs::read(&path).expect("the bytes read");
2111 for (key, value) in [
2112 ("security.advisories", "acme/private"),
2113 ("security.response", "90d"),
2114 ("profile.release.style", "unknown"),
2115 ] {
2116 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2117 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2118 }
2119 }
2120
2121 #[test]
2124 fn the_security_answers_are_held_to_their_grammar() {
2125 for value in ["team@acme.example", " https://acme.example/report ", ""] {
2126 super::canonical_contact(value).expect("a control-free line is a contact");
2127 }
2128 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2129 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2130 assert!(refusal.contains("security.contact"), "{refusal}");
2131 }
2132 assert_eq!(
2133 super::canonical_contact(" team@acme.example "),
2134 Ok("team@acme.example".to_owned()),
2135 "surrounding whitespace is trimmed"
2136 );
2137 for value in [
2138 "best-effort",
2139 "1 day",
2140 "2 days",
2141 "14 days",
2142 "1 business day",
2143 "14 business days",
2144 ] {
2145 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2146 }
2147 assert_eq!(
2148 super::canonical_response(""),
2149 Ok(super::RESPONSE_DEFAULT.to_owned()),
2150 "an empty answer reads as the compiled default"
2151 );
2152 for value in [
2153 "0 days",
2154 "1 days",
2155 "2 day",
2156 "+2 days",
2157 "02 days",
2158 "4294967296 days",
2159 "90d",
2160 "two days",
2161 "we answer quickly",
2162 "2 weeks",
2163 ] {
2164 let refusal =
2165 super::canonical_response(value).expect_err("an unstateable window refuses");
2166 assert!(refusal.contains("security.response"), "{value}: {refusal}");
2167 assert!(refusal.contains("business days"), "{value}: {refusal}");
2168 }
2169 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2170 .expect_err("the reader refuses it too")
2171 .to_string();
2172 assert!(refusal.contains("security.response"), "{refusal}");
2173 }
2174
2175 #[test]
2180 fn a_pruned_header_leaves_no_comment_behind() {
2181 let cases = [
2182 (
2183 "a middle table, comment above",
2184 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2185 ),
2186 (
2187 "a middle table, comment inline",
2188 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2189 ),
2190 (
2191 "the last table, comment above",
2192 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
2193 ),
2194 (
2195 "the last table, comment inline",
2196 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
2197 ),
2198 ];
2199 for (case, text) in cases {
2200 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2201 assert!(!next.contains("repo ="), "{case}: {next}");
2202 assert!(
2203 !next.contains("[project]"),
2204 "{case}: a table every one of whose keys dropped goes with them: {next}"
2205 );
2206 assert!(
2207 next.contains("# the operator's note"),
2208 "{case}: the comment the header carried survives: {next}"
2209 );
2210 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2211 }
2212 }
2213
2214 #[test]
2220 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2221 let text = concat!(
2222 "schema_version = 2\n\n[project]\n",
2223 "# the operator's note\n",
2224 "repo = \"acme/widget\" # P: project path on the forge\n\n",
2225 "[git]\ntrunk = \"main\"\n"
2226 );
2227 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2228 assert!(!next.contains("repo ="), "{next}");
2229 assert!(!next.contains("[project]"), "{next}");
2230 assert!(
2231 next.contains("# the operator's note"),
2232 "authored text survives: {next}"
2233 );
2234 assert!(
2235 !next.contains("# P:"),
2236 "the template's comment describes a key that is gone: {next}"
2237 );
2238 parse(&next).expect("the result parses");
2239
2240 let text = concat!(
2242 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2243 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2244 "# why this project names its own prefix\n",
2245 "line_prefix = \"stable/\"\n"
2246 );
2247 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2248 .expect("the key removes");
2249 assert!(!next.contains("line_prefix ="), "{next}");
2250 assert!(next.contains("[profile.release]"), "{next}");
2251 assert!(
2252 next.contains("# why this project names its own prefix"),
2253 "authored text survives: {next}"
2254 );
2255 }
2256}