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,
219 pub retired_branches: Vec<String>,
221 pub release_lines: bool,
223 pub excluded_steps: BTreeMap<String, String>,
228 pub bot: Bot,
230}
231
232impl Default for Setup {
233 fn default() -> Self {
234 Self {
235 required_check: String::new(),
236 retired_branches: vec!["main".into(), "develop".into()],
237 release_lines: false,
238 excluded_steps: BTreeMap::new(),
239 bot: Bot::default(),
240 }
241 }
242}
243
244#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
251#[serde(deny_unknown_fields, default)]
252pub struct Bot {
253 pub app_id: String,
255 #[serde(default, skip_serializing)]
260 pub installation_id: Option<i64>,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
265#[serde(deny_unknown_fields, default)]
266#[allow(
267 clippy::struct_excessive_bools,
268 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
269)]
270pub struct Protection {
271 pub trunk_ruleset: Option<String>,
275 pub tag_ruleset: String,
277 pub lines_ruleset: String,
279 pub title_check: String,
281 pub tag_pattern: String,
283 pub bypass_actors: Vec<String>,
285 pub allowed_merge_methods: Vec<String>,
287 pub strict_required_status_checks: bool,
289 pub owned_trunk_rules: Vec<String>,
293 pub required_approving_review_count: i64,
295 pub dismiss_stale_reviews_on_push: bool,
297 pub require_code_owner_review: bool,
299 pub require_last_push_approval: bool,
301 pub github: Github,
303 pub gitlab: Gitlab,
305}
306
307impl Default for Protection {
308 fn default() -> Self {
309 Self {
310 trunk_ruleset: None,
311 tag_ruleset: "release-tags".into(),
312 lines_ruleset: "release-lines".into(),
313 title_check: "pr-title".into(),
314 tag_pattern: "refs/tags/v*".into(),
315 bypass_actors: Vec::new(),
316 allowed_merge_methods: vec!["squash".into()],
317 strict_required_status_checks: true,
318 owned_trunk_rules: vec![
319 "deletion".into(),
320 "non_fast_forward".into(),
321 "pull_request".into(),
322 "required_status_checks".into(),
323 ],
324 required_approving_review_count: 0,
325 dismiss_stale_reviews_on_push: false,
326 require_code_owner_review: false,
327 require_last_push_approval: false,
328 github: Github::default(),
329 gitlab: Gitlab::default(),
330 }
331 }
332}
333
334impl Protection {
335 #[must_use]
338 pub fn trunk_ruleset(&self, trunk: &str) -> String {
339 self.trunk_ruleset
340 .clone()
341 .unwrap_or_else(|| format!("{trunk}-protection"))
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
347#[serde(deny_unknown_fields, default)]
348pub struct Github {
349 pub squash_title_source: String,
351 pub squash_body_source: String,
353}
354
355impl Default for Github {
356 fn default() -> Self {
357 Self {
358 squash_title_source: "PR_TITLE".into(),
359 squash_body_source: "PR_BODY".into(),
360 }
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
366#[serde(deny_unknown_fields, default)]
367pub struct Gitlab {
368 pub merge_method: String,
370 pub squash_option: String,
372 pub squash_commit_template: String,
374 pub push_access_level: i64,
379 pub merge_access_level: i64,
381}
382
383impl Default for Gitlab {
384 fn default() -> Self {
385 Self {
386 merge_method: "ff".into(),
387 squash_option: "always".into(),
388 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
389 .trim_end_matches('\n')
390 .to_owned(),
391 push_access_level: 0,
392 merge_access_level: 40,
393 }
394 }
395}
396
397pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
402 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
403 Ok(text) => text,
404 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
405 Err(error) => return Err(error.into()),
406 };
407 parse(&text).map(Some)
408}
409
410fn current_text(text: &str) -> Result<String, RkError> {
413 let raw: toml::Value =
414 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
415 match raw.get("schema_version").and_then(toml::Value::as_integer) {
416 Some(1) => migrate::to_schema_2(text),
417 _ => Ok(text.to_owned()),
418 }
419}
420
421fn parse(text: &str) -> Result<Config, RkError> {
422 let text = current_text(text)?;
423 let raw: toml::Value =
424 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
425 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
426 return Err(invalid(format!(
427 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
428 )));
429 }
430 if let Some(mode) = raw
434 .get("profile")
435 .and_then(|profile| profile.get("release"))
436 .and_then(|release| release.get("mode"))
437 {
438 let named = mode.as_str().ok_or_else(|| {
439 invalid("profile.release.mode must be a string: automatic, external, or none")
440 })?;
441 crate::profile::ReleaseMode::parse(named)
442 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
443 }
444 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
445 let mut message = error.to_string();
446 if let Some(rest) = error.message().strip_prefix("unknown field `") {
447 let names: Vec<_> = rest.split('`').collect();
448 if let Some(unknown) = names.first()
449 && let Some(nearest) = names
450 .iter()
451 .skip(2)
452 .step_by(2)
453 .min_by_key(|name| distance(unknown, name))
454 {
455 let _ = write!(message, "; nearest known key: {nearest}");
456 }
457 }
458 invalid(message)
459 })?;
460 if let Some(technologies) = &config.profile.technologies {
461 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
462 }
463 if let Some(forge) = &config.profile.forge
464 && !forge.is_empty()
465 {
466 crate::profile::canonical_category(forge)
467 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
468 }
469 if let Some(driver) = &config.profile.release.driver {
470 crate::profile::canonical_category(driver)
471 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
472 }
473 if matches!(
474 config.profile.release.mode,
475 Some(ReleaseMode::External | ReleaseMode::None)
476 ) {
477 let mode = config
478 .profile
479 .release
480 .mode
481 .map_or("none", ReleaseMode::as_str);
482 for (key, present) in [
483 (
484 "profile.release.driver",
485 config.profile.release.driver.is_some(),
486 ),
487 (
488 "profile.release.style",
489 config.profile.release.style.is_some(),
490 ),
491 (
492 "profile.release.line_prefix",
493 config.profile.release.line_prefix.is_some(),
494 ),
495 ] {
496 if present {
497 return Err(invalid(format!(
498 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
499 )));
500 }
501 }
502 }
503 if let Some(contact) = &config.security.contact {
504 canonical_contact(contact).map_err(invalid)?;
505 }
506 if let Some(response) = &config.security.response {
507 canonical_response(response).map_err(invalid)?;
508 }
509 exclusions(&config.setup.excluded_steps)?;
510 floors::check(&config)?;
511 Ok(config)
512}
513
514fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
521 for (name, reason) in excluded {
522 if crate::setup::steps::spec(name).is_none() {
523 let nearest = crate::setup::steps::STEPS
524 .iter()
525 .min_by_key(|step| distance(name, step.name))
526 .map_or("", |step| step.name);
527 return Err(invalid(format!(
528 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
529 )));
530 }
531 if reason.trim().is_empty() {
532 return Err(invalid(format!(
533 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
534 )));
535 }
536 }
537 Ok(())
538}
539
540pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
541 RkError::refusal(
542 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
543 .action(format!("edit {CONFIG_PATH} and retry"))
544 .target_state("nothing was written"),
545 )
546}
547
548fn distance(left: &str, right: &str) -> usize {
549 let mut row: Vec<_> = (0..=right.chars().count()).collect();
550 for (i, a) in left.chars().enumerate() {
551 let mut previous = row[0];
552 row[0] = i + 1;
553 for (j, b) in right.chars().enumerate() {
554 let old = row[j + 1];
555 row[j + 1] = (previous + usize::from(a != b))
556 .min(row[j] + 1)
557 .min(old + 1);
558 previous = old;
559 }
560 }
561 row.last().copied().unwrap_or(0)
562}
563
564pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
569 let bytes = render(config)?;
570 parse(&String::from_utf8_lossy(&bytes))?;
571 crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
572 Ok(())
573}
574
575fn array(values: &[String]) -> toml_edit::Value {
576 toml_edit::Value::Array(values.iter().collect())
577}
578
579fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
584 let mut table = toml_edit::InlineTable::new();
585 for (key, value) in values {
586 table.insert(key, value.clone().into());
587 }
588 toml_edit::Value::InlineTable(table)
589}
590
591#[allow(
596 clippy::too_many_lines,
597 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
598)]
599fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
600 let trunk = config
604 .git
605 .trunk
606 .clone()
607 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
608 let technologies = config
609 .profile
610 .technologies
611 .clone()
612 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
613 let forge = config
614 .profile
615 .forge
616 .clone()
617 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
618 let mode = config
619 .profile
620 .release
621 .mode
622 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
623 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
624 (
625 "RK_CONFIG_SCHEMA_VERSION",
626 Some(config.schema_version.into()),
627 ),
628 (
629 "RK_CONFIG_PROJECT_REPO",
630 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
631 ),
632 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
633 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
634 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
635 (
636 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
637 config
638 .profile
639 .release
640 .driver
641 .clone()
642 .map(toml_edit::Value::from),
643 ),
644 (
645 "RK_CONFIG_PROFILE_RELEASE_STYLE",
646 config
647 .profile
648 .release
649 .style
650 .map(|style| style.as_str().into()),
651 ),
652 (
653 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
654 config
655 .profile
656 .release
657 .line_prefix
658 .clone()
659 .map(toml_edit::Value::from),
660 ),
661 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
662 (
663 "RK_CONFIG_GIT_CHECKOUT_MODE",
664 Some(
665 config
666 .git
667 .checkout_mode
668 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
669 .as_str()
670 .into(),
671 ),
672 ),
673 (
674 "RK_CONFIG_GIT_INTEGRATION",
675 Some(
676 config
677 .git
678 .integration
679 .ok_or_else(|| invalid("git.integration is unresolved"))?
680 .as_str()
681 .into(),
682 ),
683 ),
684 (
685 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
686 Some(
687 config
688 .capabilities
689 .nix_packaging
690 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
691 .into(),
692 ),
693 ),
694 (
695 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
696 Some(
697 config
698 .capabilities
699 .reporting_policy
700 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
701 .into(),
702 ),
703 ),
704 (
705 "RK_CONFIG_CAPABILITIES_SCORECARD",
706 Some(
707 config
708 .capabilities
709 .scorecard
710 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
711 .into(),
712 ),
713 ),
714 (
715 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
716 Some(
717 config
718 .capabilities
719 .code_scanning
720 .clone()
721 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
722 .into(),
723 ),
724 ),
725 (
726 "RK_CONFIG_SECURITY_ADVISORIES",
727 Some(config.security.advisories.clone().into()),
728 ),
729 (
730 "RK_CONFIG_SECURITY_CONTACT",
731 Some(config.security.contact.clone().unwrap_or_default().into()),
732 ),
733 (
734 "RK_CONFIG_SECURITY_RESPONSE",
735 Some(
736 config
737 .security
738 .response
739 .clone()
740 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
741 .into(),
742 ),
743 ),
744 (
745 "RK_CONFIG_SETUP_REQUIRED_CHECK",
746 Some(config.setup.required_check.clone().into()),
747 ),
748 (
749 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
750 Some(array(&config.setup.retired_branches)),
751 ),
752 (
753 "RK_CONFIG_SETUP_RELEASE_LINES",
754 Some(config.setup.release_lines.into()),
755 ),
756 (
757 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
758 Some(inline(&config.setup.excluded_steps)),
759 ),
760 (
761 "RK_CONFIG_SETUP_BOT_APP_ID",
762 Some(config.setup.bot.app_id.clone().into()),
763 ),
764 ];
765 fields.extend(
766 protection_fields(&config.protection, trunk.as_str())
767 .into_iter()
768 .map(|(token, value)| (token, Some(value))),
769 );
770 Ok(fields)
771}
772
773fn render(config: &Config) -> Result<Vec<u8>, RkError> {
774 let fields = fields(config)?;
775 let template = crate::embedded::BLOCKS
776 .get_file("target-config.toml.in")
777 .and_then(include_dir::File::contents_utf8)
778 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
779 let lines: Vec<&str> = template.split_inclusive('\n').collect();
785 let dropped: Vec<bool> = lines
786 .iter()
787 .map(|line| {
788 matches!(
789 fields.iter().find(|(token, _)| line.contains(token)),
790 Some((_, None))
791 )
792 })
793 .collect();
794 let mut bytes = Vec::new();
795 let mut at = 0;
796 while at < lines.len() {
797 let line = lines[at];
798 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
799 let mut end = at + 1;
800 let mut keeps = false;
801 while end < lines.len()
802 && !(lines[end].trim_start().starts_with('[')
803 && lines[end].trim_end().ends_with(']'))
804 {
805 keeps |=
806 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
807 end += 1;
808 }
809 if !keeps {
810 while bytes.last() == Some(&b'\n')
812 && bytes.len() >= 2
813 && bytes[bytes.len() - 2] == b'\n'
814 {
815 bytes.pop();
816 }
817 at = end;
818 continue;
819 }
820 }
821 if !dropped[at] {
822 match fields.iter().find(|(token, _)| line.contains(token)) {
823 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
824 line.as_bytes(),
825 token.as_bytes(),
826 value.to_string().as_bytes(),
827 )),
828 Some((_, None)) => {}
829 None => bytes.extend_from_slice(line.as_bytes()),
830 }
831 }
832 at += 1;
833 }
834 Ok(bytes)
835}
836
837fn protection_fields(
838 protection: &Protection,
839 trunk: &str,
840) -> Vec<(&'static str, toml_edit::Value)> {
841 vec![
842 (
843 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
844 protection.trunk_ruleset(trunk).into(),
845 ),
846 (
847 "RK_CONFIG_PROTECTION_TAG_RULESET",
848 protection.tag_ruleset.clone().into(),
849 ),
850 (
851 "RK_CONFIG_PROTECTION_LINES_RULESET",
852 protection.lines_ruleset.clone().into(),
853 ),
854 (
855 "RK_CONFIG_PROTECTION_TITLE_CHECK",
856 protection.title_check.clone().into(),
857 ),
858 (
859 "RK_CONFIG_PROTECTION_TAG_PATTERN",
860 protection.tag_pattern.clone().into(),
861 ),
862 (
863 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
864 array(&protection.bypass_actors),
865 ),
866 (
867 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
868 array(&protection.allowed_merge_methods),
869 ),
870 (
871 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
872 protection.strict_required_status_checks.into(),
873 ),
874 (
875 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
876 array(&protection.owned_trunk_rules),
877 ),
878 (
879 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
880 protection.required_approving_review_count.into(),
881 ),
882 (
883 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
884 protection.dismiss_stale_reviews_on_push.into(),
885 ),
886 (
887 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
888 protection.require_code_owner_review.into(),
889 ),
890 (
891 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
892 protection.require_last_push_approval.into(),
893 ),
894 (
895 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
896 protection.github.squash_title_source.clone().into(),
897 ),
898 (
899 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
900 protection.github.squash_body_source.clone().into(),
901 ),
902 (
903 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
904 protection.gitlab.merge_method.clone().into(),
905 ),
906 (
907 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
908 protection.gitlab.squash_option.clone().into(),
909 ),
910 (
911 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
912 protection.gitlab.squash_commit_template.clone().into(),
913 ),
914 (
915 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
916 protection.gitlab.push_access_level.into(),
917 ),
918 (
919 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
920 protection.gitlab.merge_access_level.into(),
921 ),
922 ]
923}
924
925const DERIVED_POLICY_KEYS: [&str; 2] = [
935 "protection.owned_trunk_rules",
936 "protection.gitlab.push_access_level",
937];
938
939const PARAMETER_KEYS: [&str; 19] = [
941 "project.repo",
942 "profile.technologies",
943 "profile.forge",
944 "profile.release.mode",
945 "profile.release.driver",
946 "profile.release.style",
947 "profile.release.line_prefix",
948 "git.trunk",
949 "git.checkout_mode",
950 "git.integration",
951 "protection.owned_trunk_rules",
955 "protection.gitlab.push_access_level",
956 "capabilities.nix_packaging",
957 "capabilities.reporting_policy",
958 "capabilities.scorecard",
959 "capabilities.code_scanning",
960 "security.contact",
961 "security.response",
962 "schema_version",
963];
964
965pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
970 let path = target.join(CONFIG_PATH);
971 let text = std::fs::read_to_string(&path)?;
972 let next = rewrite_text(&text, key, Some(value))?;
973 crate::atomic::write(&path, next.as_bytes())?;
974 Ok(())
975}
976
977fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
980 rewrite_all(text, vec![(key, value)])
981}
982
983fn rewrite_all(
991 text: &str,
992 values: Vec<(&str, Option<toml_edit::Value>)>,
993) -> Result<String, RkError> {
994 for (key, _) in &values {
995 if !PARAMETER_KEYS.contains(key) {
996 return Err(invalid(format!("{key} is not a landing parameter")));
997 }
998 }
999 parse(text)?;
1000 let text = current_text(text)?;
1001 let mut document = text
1002 .parse::<toml_edit::DocumentMut>()
1003 .map_err(|error| invalid(error.to_string()))?;
1004 let mut changed = false;
1005 let mut emptied: Vec<&str> = Vec::new();
1008 for (key, value) in values {
1009 let removing = value.is_none();
1010 let applied = apply_key(&mut document, key, value)?;
1011 changed |= applied;
1012 if applied
1013 && removing
1014 && let Some(parent) = key.split('.').next()
1015 {
1016 emptied.push(parent);
1017 }
1018 }
1019 changed |= prune_empty_tables(&mut document, &emptied);
1020 changed |= migrate::refresh_comments(&mut document);
1027 if !changed {
1028 return Ok(text);
1029 }
1030 let next = document.to_string();
1031 parse(&next)?;
1032 Ok(next)
1033}
1034
1035pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1054 let order: Vec<String> = document
1055 .as_table()
1056 .iter()
1057 .map(|(key, _)| key.to_owned())
1058 .collect();
1059 let mut changed = false;
1060 for (index, name) in order.iter().enumerate() {
1061 if !names.contains(&name.as_str())
1062 || !document
1063 .get(name)
1064 .and_then(toml_edit::Item::as_table)
1065 .is_some_and(toml_edit::Table::is_empty)
1066 {
1067 continue;
1068 }
1069 let carried = document
1070 .get(name)
1071 .and_then(toml_edit::Item::as_table)
1072 .and_then(|table| carried_comment(table.decor()));
1073 document.remove(name);
1074 changed = true;
1075 let Some(carried) = carried else { continue };
1076 let next = order
1080 .iter()
1081 .skip(index + 1)
1082 .find(|name| {
1083 document
1084 .get(name)
1085 .and_then(toml_edit::Item::as_table)
1086 .is_some_and(|table| !table.is_implicit())
1087 })
1088 .cloned();
1089 if let Some(next) = next
1090 && let Some(table) = document
1091 .get_mut(&next)
1092 .and_then(toml_edit::Item::as_table_mut)
1093 {
1094 let existing = table
1095 .decor()
1096 .prefix()
1097 .and_then(toml_edit::RawString::as_str)
1098 .unwrap_or_default()
1099 .trim_start_matches('\n')
1100 .to_owned();
1101 table
1102 .decor_mut()
1103 .set_prefix(format!("\n{carried}{existing}"));
1104 } else {
1105 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1106 if !trailing.is_empty() && !trailing.ends_with('\n') {
1107 trailing.push('\n');
1108 }
1109 trailing.push_str(&carried);
1110 document.set_trailing(trailing);
1111 }
1112 }
1113 changed
1114}
1115
1116fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1123 let mut lines = String::new();
1124 for raw in [decor.prefix(), decor.suffix()] {
1125 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1126 continue;
1127 };
1128 for line in text
1129 .lines()
1130 .map(str::trim)
1131 .filter(|line| line.starts_with('#'))
1132 .filter(|line| !is_template_comment(line))
1133 {
1134 lines.push_str(line);
1135 lines.push('\n');
1136 }
1137 }
1138 (!lines.is_empty()).then_some(lines)
1139}
1140
1141fn is_template_comment(line: &str) -> bool {
1150 let rest = line.trim_start_matches('#').trim_start();
1151 ["P:", "N:", "F:"]
1152 .iter()
1153 .any(|marker| rest.starts_with(marker))
1154}
1155
1156pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1164 let first = document
1165 .as_table()
1166 .iter()
1167 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1168 .map(|(name, _)| name.to_owned());
1169 if let Some(first) = first
1170 && let Some(table) = document
1171 .get_mut(&first)
1172 .and_then(toml_edit::Item::as_table_mut)
1173 {
1174 let existing = table
1175 .decor()
1176 .prefix()
1177 .and_then(toml_edit::RawString::as_str)
1178 .unwrap_or_default()
1179 .trim_start_matches('\n')
1180 .to_owned();
1181 table
1182 .decor_mut()
1183 .set_prefix(format!("\n{carried}{existing}"));
1184 return;
1185 }
1186 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1187 if !trailing.is_empty() && !trailing.ends_with('\n') {
1188 trailing.push('\n');
1189 }
1190 trailing.push_str(carried);
1191 document.set_trailing(trailing);
1192}
1193
1194pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1200 let (name, _) = table.get_key_value(key)?;
1201 carried_comment(name.leaf_decor())
1202}
1203
1204pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1206 let Some(mut name) = table.key_mut(key) else {
1207 return;
1208 };
1209 let decor = name.leaf_decor_mut();
1210 let existing = decor
1211 .prefix()
1212 .and_then(toml_edit::RawString::as_str)
1213 .unwrap_or_default()
1214 .trim_start_matches('\n')
1215 .to_owned();
1216 decor.set_prefix(format!("\n{carried}{existing}"));
1217}
1218
1219#[must_use]
1221pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1222 known
1223 .iter()
1224 .min_by_key(|name| distance(unknown, name))
1225 .copied()
1226}
1227
1228pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1231 let carried = carried_comment(table.decor())?;
1232 table.decor_mut().set_prefix("\n");
1233 Some(carried)
1234}
1235
1236pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1247 let carried = table.get_key_value(key).and_then(|(name, item)| {
1248 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1249 if let Some(value) = item.as_value()
1250 && let Some(more) = carried_comment(value.decor())
1251 {
1252 lines.push_str(&more);
1253 }
1254 (!lines.is_empty()).then_some(lines)
1255 });
1256 let removed = table.remove(key).is_some();
1257 if let Some(carried) = carried {
1258 let existing = table
1259 .decor()
1260 .prefix()
1261 .and_then(toml_edit::RawString::as_str)
1262 .unwrap_or_default()
1263 .trim_start_matches('\n')
1264 .to_owned();
1265 table
1266 .decor_mut()
1267 .set_prefix(format!("\n{carried}{existing}"));
1268 }
1269 removed
1270}
1271
1272fn apply_key(
1275 document: &mut toml_edit::DocumentMut,
1276 key: &str,
1277 value: Option<toml_edit::Value>,
1278) -> Result<bool, RkError> {
1279 let segments: Vec<&str> = key.split('.').collect();
1280 let Some((last, parents)) = segments.split_last() else {
1283 return Err(invalid(format!("{key} names no key")));
1284 };
1285 let Some(mut value) = value else {
1286 let mut item = document.as_item_mut();
1287 for segment in parents {
1288 if item.get(segment).is_none() {
1289 return Ok(false);
1290 }
1291 item = &mut item[segment];
1292 }
1293 let removed = item
1294 .as_table_mut()
1295 .is_some_and(|table| take_comments(table, last));
1296 return Ok(removed);
1297 };
1298 let mut item = document.as_item_mut();
1299 for segment in parents {
1300 if item.get(segment).is_none() {
1301 let mut table = toml_edit::Table::new();
1302 table.set_implicit(true);
1303 item[segment] = toml_edit::Item::Table(table);
1304 }
1305 item = &mut item[segment];
1306 }
1307 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1308 if old.to_string().trim() == value.to_string().trim() {
1309 return Ok(false);
1310 }
1311 *value.decor_mut() = old.decor().clone();
1312 } else if let Some(comment) = migrate::template_comment(&segments) {
1313 value.decor_mut().set_suffix(comment);
1319 }
1320 item[last] = toml_edit::Item::Value(value);
1321 Ok(true)
1322}
1323
1324#[derive(Debug, Clone, serde::Serialize)]
1326pub struct Plan {
1327 pub action: &'static str,
1329 pub changes: Vec<String>,
1331 pub content: String,
1333}
1334
1335impl Plan {
1336 pub fn new(
1341 target: &Path,
1342 params: &crate::landing::Params,
1343 existing: Option<&Config>,
1344 record: Option<&crate::landing::manifest::Manifest>,
1345 ) -> Result<Self, RkError> {
1346 let text = existing
1347 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1348 .transpose()?;
1349 Self::compose(text.as_deref(), params, existing, record)
1350 }
1351
1352 pub fn compose(
1359 text: Option<&str>,
1360 params: &crate::landing::Params,
1361 existing: Option<&Config>,
1362 record: Option<&crate::landing::manifest::Manifest>,
1363 ) -> Result<Self, RkError> {
1364 let mut resolved = existing.cloned().unwrap_or_default();
1365 resolved.schema_version = SCHEMA_VERSION;
1366 params.repo().clone_into(&mut resolved.project.repo);
1367 resolved.profile = Profile {
1368 technologies: Some(params.technologies().to_vec()),
1369 forge: Some(params.forge().unwrap_or_default().to_owned()),
1370 release: Release {
1371 mode: Some(params.release_mode()),
1372 driver: params.driver().map(str::to_owned),
1373 style: params.style(),
1374 line_prefix: params.profile().release.line_prefix.clone(),
1375 },
1376 };
1377 resolved.git = Git {
1378 trunk: Some(params.trunk().to_owned()),
1379 checkout_mode: Some(params.checkout_mode()),
1380 integration: Some(params.integration()),
1381 };
1382 resolved.protection = protection_for(
1383 &resolved.protection,
1384 params.integration(),
1385 existing.is_none(),
1386 );
1387 resolved.capabilities = Capabilities {
1388 nix_packaging: Some(params.nix_packaging()),
1389 reporting_policy: Some(params.reporting_policy()),
1390 scorecard: Some(params.scorecard()),
1391 code_scanning: Some(
1392 params
1393 .code_scanning()
1394 .map_or("off", crate::landing::Provider::as_str)
1395 .to_owned(),
1396 ),
1397 };
1398 resolved.security.contact = Some(params.security_contact().to_owned());
1399 resolved.security.response = Some(params.security_response().to_owned());
1400 let content = if let Some(text) = text.filter(|_| existing.is_some()) {
1401 rewrite_all(text, parameter_values(&resolved))?
1402 } else {
1403 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
1404 };
1405 parse(&content)?;
1406 Ok(Self {
1407 action: if existing.is_some() {
1408 "updated"
1409 } else {
1410 "added"
1411 },
1412 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1413 content,
1414 })
1415 }
1416
1417 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1422 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
1423 Ok(())
1424 }
1425}
1426
1427fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1429 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1430 "project.repo",
1431 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1432 )];
1433 if let Some(list) = &config.profile.technologies {
1434 values.push(("profile.technologies", Some(array(list))));
1435 }
1436 if let Some(forge) = config.profile.forge.clone() {
1437 values.push(("profile.forge", Some(forge.into())));
1438 }
1439 if let Some(mode) = config.profile.release.mode {
1440 values.push(("profile.release.mode", Some(mode.as_str().into())));
1441 values.push((
1442 "profile.release.driver",
1443 config
1444 .profile
1445 .release
1446 .driver
1447 .clone()
1448 .map(toml_edit::Value::from),
1449 ));
1450 values.push((
1451 "profile.release.style",
1452 config
1453 .profile
1454 .release
1455 .style
1456 .map(|style| style.as_str().into()),
1457 ));
1458 values.push((
1459 "profile.release.line_prefix",
1460 config
1461 .profile
1462 .release
1463 .line_prefix
1464 .clone()
1465 .map(toml_edit::Value::from),
1466 ));
1467 }
1468 if let Some(trunk) = config.git.trunk.clone() {
1469 values.push(("git.trunk", Some(trunk.into())));
1470 }
1471 if let Some(mode) = config.git.checkout_mode {
1472 values.push(("git.checkout_mode", Some(mode.as_str().into())));
1473 }
1474 if let Some(mode) = config.git.integration {
1475 values.push(("git.integration", Some(mode.as_str().into())));
1476 let mut rules = toml_edit::Array::new();
1480 for rule in &config.protection.owned_trunk_rules {
1481 rules.push(rule.as_str());
1482 }
1483 values.push((
1484 "protection.owned_trunk_rules",
1485 Some(toml_edit::Value::Array(rules)),
1486 ));
1487 values.push((
1488 "protection.gitlab.push_access_level",
1489 Some(config.protection.gitlab.push_access_level.into()),
1490 ));
1491 }
1492 if let Some(value) = config.capabilities.nix_packaging {
1493 values.push(("capabilities.nix_packaging", Some(value.into())));
1494 }
1495 if let Some(value) = config.capabilities.reporting_policy {
1496 values.push(("capabilities.reporting_policy", Some(value.into())));
1497 }
1498 if let Some(value) = config.capabilities.scorecard {
1499 values.push(("capabilities.scorecard", Some(value.into())));
1500 }
1501 if let Some(value) = config.capabilities.code_scanning.clone() {
1502 values.push(("capabilities.code_scanning", Some(value.into())));
1503 }
1504 if let Some(value) = config.security.contact.clone() {
1507 values.push(("security.contact", Some(value.into())));
1508 }
1509 if let Some(value) = config.security.response.clone() {
1510 values.push(("security.response", Some(value.into())));
1511 }
1512 values
1513}
1514
1515#[must_use]
1517pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
1518 let recorded = {
1519 let params = crate::landing::Params::from_record(record);
1520 Plan::compose(None, ¶ms, None, None)
1521 .ok()
1522 .and_then(|plan| parse(&plan.content).ok())
1523 };
1524 let Some(recorded) = recorded else {
1525 return Vec::new();
1526 };
1527 let render = |value: &Option<toml_edit::Value>| {
1528 value.as_ref().map_or_else(
1529 || "<absent>".to_owned(),
1530 |value| value.to_string().trim().to_owned(),
1531 )
1532 };
1533 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
1534 .iter()
1535 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1536 .map(|(key, value)| (*key, render(value)))
1537 .collect();
1538 parameter_values(config)
1539 .into_iter()
1540 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1541 .map(|(key, value)| (key, render(&value)))
1542 .filter(|(key, value)| {
1543 baseline
1544 .iter()
1545 .any(|(other, old)| key == other && value != old)
1546 })
1547 .map(|(key, _)| key.to_owned())
1548 .collect()
1549}
1550
1551#[must_use]
1559fn local_protection() -> Protection {
1560 const MAINTAINER: i64 = 40;
1562 let mut policy = Protection::default();
1563 policy
1564 .owned_trunk_rules
1565 .retain(|rule| rule != "pull_request" && rule != "required_status_checks");
1566 policy.gitlab.push_access_level = MAINTAINER;
1567 policy
1568}
1569
1570#[must_use]
1591fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
1592 let forge = Protection::default();
1593 let local = local_protection();
1594 let pair = |policy: &Protection| {
1595 (
1596 policy.owned_trunk_rules.clone(),
1597 policy.gitlab.push_access_level,
1598 )
1599 };
1600 let taken = fresh || pair(held) == pair(&forge) || pair(held) == pair(&local);
1601 if !taken {
1602 return held.clone();
1603 }
1604 let mut next = held.clone();
1605 let source = match integration {
1606 Integration::Forge => forge,
1607 Integration::Local => local,
1608 };
1609 next.owned_trunk_rules = source.owned_trunk_rules;
1610 next.gitlab.push_access_level = source.gitlab.push_access_level;
1611 next
1612}
1613
1614pub fn trunk_of(target: &Path) -> Result<String, RkError> {
1619 Ok(load(target)?
1620 .and_then(|config| config.git.trunk)
1621 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
1622}
1623
1624pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
1629 Ok(load(target)?
1630 .and_then(|config| config.profile.release.line_prefix)
1631 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
1637 use crate::landing::{CheckoutMode, Integration, Style};
1638 use crate::profile::ReleaseMode;
1639
1640 fn resolved_defaults() -> Config {
1643 Config {
1644 project: super::Project {
1645 repo: "acme/widget".into(),
1646 },
1647 profile: super::Profile {
1648 technologies: Some(vec!["rust".into()]),
1649 forge: Some("github".into()),
1650 release: super::Release {
1651 mode: Some(ReleaseMode::Automatic),
1652 driver: Some("rust".into()),
1653 style: Some(Style::Trunk),
1654 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
1655 },
1656 },
1657 git: super::Git {
1658 trunk: Some(super::TRUNK_DEFAULT.into()),
1659 checkout_mode: Some(CheckoutMode::LinkedWorktree),
1660 integration: Some(Integration::Local),
1661 },
1662 capabilities: super::Capabilities {
1663 nix_packaging: Some(false),
1664 reporting_policy: Some(true),
1665 scorecard: Some(false),
1666 code_scanning: Some("off".to_owned()),
1667 },
1668 security: super::Security {
1671 contact: Some(String::new()),
1672 response: Some(super::RESPONSE_DEFAULT.into()),
1673 ..super::Security::default()
1674 },
1675 protection: super::Protection {
1678 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
1679 ..super::Protection::default()
1680 },
1681 ..Config::default()
1682 }
1683 }
1684
1685 #[test]
1686 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
1687 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
1688 let explicit = parse(
1689 "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",
1690 )
1691 .expect("explicit defaults parse");
1692 assert_eq!(omitted.git, super::Git::default());
1693 assert_eq!(omitted.capabilities, super::Capabilities::default());
1694 assert_eq!(
1695 explicit.git.checkout_mode,
1696 Some(CheckoutMode::LinkedWorktree)
1697 );
1698 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
1699 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
1700 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
1701 assert_eq!(explicit.capabilities.scorecard, Some(false));
1702 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
1703 assert_ne!(omitted, explicit);
1704 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
1706 .expect("the older spelling reads");
1707 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
1708 }
1709
1710 #[test]
1714 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
1715 let dir = tempfile::tempdir().expect("a tempdir");
1716 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
1717 std::fs::write(
1718 dir.path().join(CONFIG_PATH),
1719 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
1720 )
1721 .expect("the config writes");
1722 let held = load(dir.path())
1723 .expect("the config reads")
1724 .expect("it is present");
1725 assert_eq!(held.setup.bot.app_id, "123");
1726 assert_eq!(
1727 held.setup.bot.installation_id,
1728 Some(0),
1729 "the key parses; nothing reads it"
1730 );
1731 }
1732
1733 #[test]
1735 fn a_schema_1_config_migrates_into_its_domains() {
1736 let held = parse(
1737 "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",
1738 )
1739 .expect("a schema 1 file reads");
1740 assert_eq!(held.schema_version, 2);
1741 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
1742 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
1743 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
1744 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
1745 assert_eq!(held.profile.release.style, Some(Style::Lines));
1746 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
1747 assert_eq!(held.git.trunk.as_deref(), Some("main"));
1748 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
1749 assert_eq!(held.capabilities.nix_packaging, Some(false));
1750 assert_eq!(held.capabilities.reporting_policy, Some(true));
1751 }
1752
1753 #[test]
1755 fn every_invalid_release_state_names_its_key() {
1756 for (text, key) in [
1757 (
1758 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
1759 "profile.release.style",
1760 ),
1761 (
1762 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
1763 "profile.release.driver",
1764 ),
1765 (
1766 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
1767 "profile.release.line_prefix",
1768 ),
1769 (
1770 "[profile]\ntechnologies = ['rust', 'rust']\n",
1771 "profile.technologies",
1772 ),
1773 (
1774 "[profile]\ntechnologies = ['Rust!']\n",
1775 "profile.technologies",
1776 ),
1777 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
1778 (
1779 "[profile.release]\nmode = 'manual'\n",
1780 "profile.release.mode",
1781 ),
1782 ] {
1783 let error = parse(&format!("schema_version = 2\n{text}"))
1784 .expect_err("an invalid release state refuses")
1785 .to_string();
1786 assert!(error.contains(key), "{key}: {error}");
1787 }
1788 }
1789
1790 #[test]
1794 fn a_newly_written_key_takes_the_templates_comment() {
1795 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
1796 let next = super::rewrite_text(
1797 text,
1798 "git.integration",
1799 Some(toml_edit::Value::from("local")),
1800 )
1801 .expect("the key writes");
1802 assert!(
1803 next.contains("integration = \"local\" # P: local or forge"),
1804 "{next}"
1805 );
1806 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
1808 .expect("the key writes");
1809 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
1810 }
1811
1812 #[test]
1813 fn the_landed_config_template_round_trips() {
1814 let dir = tempfile::tempdir().expect("a target exists");
1815 let mut config = Config::default();
1816 config.project.repo = "acme/nested/widget".into();
1817 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
1818 config.profile.forge = Some("gitlab".into());
1819 config.profile.release = super::Release {
1820 mode: Some(ReleaseMode::Automatic),
1821 driver: Some("bash".into()),
1822 style: Some(Style::Lines),
1823 line_prefix: Some("stable/".into()),
1824 };
1825 config.git.trunk = Some("main".into());
1826 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
1827 config.git.integration = Some(Integration::Forge);
1828 config.capabilities.nix_packaging = Some(true);
1829 config.capabilities.reporting_policy = Some(false);
1830 config.capabilities.scorecard = Some(true);
1831 config.capabilities.code_scanning = Some("semgrep".to_owned());
1832 config.security.advisories =
1836 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
1837 config.security.contact = Some("security team, room 3 \"the vault\"".into());
1838 config.security.response = Some("14 business days".into());
1839 config.setup.required_check = "build / test".into();
1840 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
1841 config.setup.release_lines = true;
1842 config.setup.excluded_steps = [
1843 (
1844 "package-check".to_owned(),
1845 "nothing is published".to_owned(),
1846 ),
1847 (
1848 "protect-trunk".to_owned(),
1849 "this project merges \"locally\"".to_owned(),
1850 ),
1851 ]
1852 .into_iter()
1853 .collect();
1854 config.setup.bot.app_id = "123".into();
1855 config.protection.trunk_ruleset = Some("primary".into());
1856 config.protection.tag_ruleset = "versions".into();
1857 config.protection.lines_ruleset = "maintenance".into();
1858 config.protection.title_check = "intent".into();
1859 config.protection.tag_pattern = "refs/tags/*".into();
1860 config
1861 .protection
1862 .owned_trunk_rules
1863 .push("required_signatures".into());
1864 config.protection.required_approving_review_count = 2;
1865 config.protection.dismiss_stale_reviews_on_push = true;
1866 config.protection.require_code_owner_review = true;
1867 config.protection.require_last_push_approval = true;
1868 config.protection.gitlab.squash_commit_template =
1869 "%{title}\n\nContext: %{description}".into();
1870 config.protection.gitlab.merge_access_level = 40;
1871 let mut release_less = resolved_defaults();
1874 release_less.project.repo = String::new();
1875 release_less.profile.technologies = Some(Vec::new());
1876 release_less.profile.forge = Some(String::new());
1877 release_less.profile.release = super::Release {
1878 mode: Some(ReleaseMode::None),
1879 driver: None,
1880 style: None,
1881 line_prefix: None,
1882 };
1883 release_less.capabilities.reporting_policy = Some(false);
1884 for expected in [resolved_defaults(), config, release_less] {
1885 write(dir.path(), &expected).expect("the template renders");
1886 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
1887 let text =
1888 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1889 assert!(text.contains("# P: every technology"));
1890 assert!(text.contains("# F: invariant"));
1891 }
1892 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1893 assert!(
1894 !text.contains("style ="),
1895 "a none release writes no style: {text}"
1896 );
1897 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
1898 }
1899
1900 #[test]
1901 fn a_config_with_an_unknown_key_refuses_by_name() {
1902 for (table, typo, nearest) in [
1903 ("", "schemax_version", "schema_version"),
1904 ("git", "trunkx", "trunk"),
1905 ("profile.release", "stile", "style"),
1906 ("capabilities", "nix_packagingg", "nix_packaging"),
1907 ("security", "contactx", "contact"),
1908 ("setup", "required_checkx", "required_check"),
1909 ("setup.bot", "app_i", "app_id"),
1910 ("protection", "trunk_rulesett", "trunk_ruleset"),
1911 (
1912 "protection.github",
1913 "squash_body_sourcex",
1914 "squash_body_source",
1915 ),
1916 ("protection.gitlab", "squash_optionx", "squash_option"),
1917 ] {
1918 let header = if table.is_empty() {
1919 String::new()
1920 } else {
1921 format!("[{table}]\n")
1922 };
1923 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
1924 let error = parse(&text).expect_err("unknown keys refuse").to_string();
1925 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
1926 assert!(error.contains(expected), "{error}");
1927 }
1928 }
1929 }
1930
1931 #[test]
1935 fn an_exclusion_names_a_real_step_and_states_why() {
1936 for (text, expected) in [
1937 (
1938 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
1939 vec!["protect-trunkk", "nearest known step: protect-trunk"],
1940 ),
1941 (
1942 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
1943 vec!["protect-trunk", "no reason"],
1944 ),
1945 ] {
1946 let error = parse(&format!("schema_version = 2\n{text}"))
1947 .expect_err("the exclusion refuses")
1948 .to_string();
1949 for want in expected {
1950 assert!(error.contains(want), "{error}");
1951 }
1952 }
1953 let held = parse(
1954 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
1955 )
1956 .expect("a named step with a reason parses");
1957 assert_eq!(
1958 held.setup
1959 .excluded_steps
1960 .get("protect-trunk")
1961 .map(String::as_str),
1962 Some("we merge locally")
1963 );
1964 }
1965
1966 #[test]
1970 fn an_exclusion_does_not_lift_a_floor() {
1971 let error = parse(
1972 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
1973 )
1974 .expect_err("the floor binds an excluded step's keys too")
1975 .to_string();
1976 assert!(
1977 error.contains("protection.allowed_merge_methods"),
1978 "{error}"
1979 );
1980 }
1981
1982 #[test]
1983 fn a_config_at_an_unknown_schema_refuses() {
1984 for text in ["schema_version = 999", "schema_version = '2'", ""] {
1985 let error = parse(text)
1986 .expect_err("a schema must be declared and known")
1987 .to_string();
1988 assert!(
1989 error.contains(CONFIG_PATH) && error.contains("schema_version"),
1990 "{error}"
1991 );
1992 }
1993 }
1994
1995 #[test]
1996 fn an_unparsable_config_refuses_naming_the_position() {
1997 let error = parse("schema_version = 2\n[project\n")
1998 .expect_err("bad TOML refuses")
1999 .to_string();
2000 for expected in [CONFIG_PATH, "line 2", "column"] {
2001 assert!(error.contains(expected), "{error}");
2002 }
2003 }
2004
2005 #[test]
2006 fn an_absent_config_reads_as_none() {
2007 let dir = tempfile::tempdir().expect("a target exists");
2008 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2009 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2010 }
2011
2012 #[test]
2013 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2014 let dir = tempfile::tempdir().expect("a target exists");
2015 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2016 std::fs::write(
2017 dir.path().join(CONFIG_PATH),
2018 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2019 )
2020 .expect("a config exists");
2021 let error =
2022 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2023 assert_eq!(error.exit_code(), 73);
2024 assert!(
2025 error
2026 .to_string()
2027 .contains("protection.strict_required_status_checks")
2028 );
2029 }
2030
2031 #[test]
2032 fn rewrite_key_preserves_comments() {
2033 let dir = tempfile::tempdir().expect("a target exists");
2034 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2035 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";
2036 let path = dir.path().join(CONFIG_PATH);
2037 std::fs::write(&path, original).expect("a config exists");
2038 rewrite_key(dir.path(), "profile.release.style", "lines".into())
2039 .expect("the style writes back");
2040 let text = std::fs::read_to_string(&path).expect("the text reads");
2041 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2042 assert_eq!(
2043 load(dir.path())
2044 .expect("the config reads")
2045 .expect("present")
2046 .profile
2047 .release
2048 .style,
2049 Some(Style::Lines)
2050 );
2051 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2052 .expect("an omitted table can be added");
2053 assert_eq!(
2054 load(dir.path())
2055 .expect("reads")
2056 .expect("present")
2057 .project
2058 .repo,
2059 "acme/widget"
2060 );
2061 rewrite_key(
2062 dir.path(),
2063 "security.contact",
2064 "security@acme.example".into(),
2065 )
2066 .expect("the contact is a landing parameter");
2067 rewrite_key(dir.path(), "security.response", "14 days".into())
2068 .expect("the response is a landing parameter");
2069 let held = load(dir.path()).expect("reads").expect("present");
2070 assert_eq!(
2071 held.security.contact.as_deref(),
2072 Some("security@acme.example")
2073 );
2074 assert_eq!(held.security.response.as_deref(), Some("14 days"));
2075 let text = std::fs::read_to_string(&path).expect("the text reads");
2076 assert!(text.contains("# keep me"), "the comment survives: {text}");
2077 let before = std::fs::read(&path).expect("the bytes read");
2078 for (key, value) in [
2079 ("security.advisories", "acme/private"),
2080 ("security.response", "90d"),
2081 ("profile.release.style", "unknown"),
2082 ] {
2083 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2084 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2085 }
2086 }
2087
2088 #[test]
2091 fn the_security_answers_are_held_to_their_grammar() {
2092 for value in ["team@acme.example", " https://acme.example/report ", ""] {
2093 super::canonical_contact(value).expect("a control-free line is a contact");
2094 }
2095 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2096 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2097 assert!(refusal.contains("security.contact"), "{refusal}");
2098 }
2099 assert_eq!(
2100 super::canonical_contact(" team@acme.example "),
2101 Ok("team@acme.example".to_owned()),
2102 "surrounding whitespace is trimmed"
2103 );
2104 for value in [
2105 "best-effort",
2106 "1 day",
2107 "2 days",
2108 "14 days",
2109 "1 business day",
2110 "14 business days",
2111 ] {
2112 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2113 }
2114 assert_eq!(
2115 super::canonical_response(""),
2116 Ok(super::RESPONSE_DEFAULT.to_owned()),
2117 "an empty answer reads as the compiled default"
2118 );
2119 for value in [
2120 "0 days",
2121 "1 days",
2122 "2 day",
2123 "+2 days",
2124 "02 days",
2125 "4294967296 days",
2126 "90d",
2127 "two days",
2128 "we answer quickly",
2129 "2 weeks",
2130 ] {
2131 let refusal =
2132 super::canonical_response(value).expect_err("an unstateable window refuses");
2133 assert!(refusal.contains("security.response"), "{value}: {refusal}");
2134 assert!(refusal.contains("business days"), "{value}: {refusal}");
2135 }
2136 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2137 .expect_err("the reader refuses it too")
2138 .to_string();
2139 assert!(refusal.contains("security.response"), "{refusal}");
2140 }
2141
2142 #[test]
2147 fn a_pruned_header_leaves_no_comment_behind() {
2148 let cases = [
2149 (
2150 "a middle table, comment above",
2151 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2152 ),
2153 (
2154 "a middle table, comment inline",
2155 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2156 ),
2157 (
2158 "the last table, comment above",
2159 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
2160 ),
2161 (
2162 "the last table, comment inline",
2163 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
2164 ),
2165 ];
2166 for (case, text) in cases {
2167 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2168 assert!(!next.contains("repo ="), "{case}: {next}");
2169 assert!(
2170 !next.contains("[project]"),
2171 "{case}: a table every one of whose keys dropped goes with them: {next}"
2172 );
2173 assert!(
2174 next.contains("# the operator's note"),
2175 "{case}: the comment the header carried survives: {next}"
2176 );
2177 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2178 }
2179 }
2180
2181 #[test]
2187 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2188 let text = concat!(
2189 "schema_version = 2\n\n[project]\n",
2190 "# the operator's note\n",
2191 "repo = \"acme/widget\" # P: project path on the forge\n\n",
2192 "[git]\ntrunk = \"main\"\n"
2193 );
2194 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2195 assert!(!next.contains("repo ="), "{next}");
2196 assert!(!next.contains("[project]"), "{next}");
2197 assert!(
2198 next.contains("# the operator's note"),
2199 "authored text survives: {next}"
2200 );
2201 assert!(
2202 !next.contains("# P:"),
2203 "the template's comment describes a key that is gone: {next}"
2204 );
2205 parse(&next).expect("the result parses");
2206
2207 let text = concat!(
2209 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2210 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2211 "# why this project names its own prefix\n",
2212 "line_prefix = \"stable/\"\n"
2213 );
2214 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2215 .expect("the key removes");
2216 assert!(!next.contains("line_prefix ="), "{next}");
2217 assert!(next.contains("[profile.release]"), "{next}");
2218 assert!(
2219 next.contains("# why this project names its own prefix"),
2220 "authored text survives: {next}"
2221 );
2222 }
2223}