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 if !changed {
1021 return Ok(text);
1022 }
1023 let next = document.to_string();
1024 parse(&next)?;
1025 Ok(next)
1026}
1027
1028pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1047 let order: Vec<String> = document
1048 .as_table()
1049 .iter()
1050 .map(|(key, _)| key.to_owned())
1051 .collect();
1052 let mut changed = false;
1053 for (index, name) in order.iter().enumerate() {
1054 if !names.contains(&name.as_str())
1055 || !document
1056 .get(name)
1057 .and_then(toml_edit::Item::as_table)
1058 .is_some_and(toml_edit::Table::is_empty)
1059 {
1060 continue;
1061 }
1062 let carried = document
1063 .get(name)
1064 .and_then(toml_edit::Item::as_table)
1065 .and_then(|table| carried_comment(table.decor()));
1066 document.remove(name);
1067 changed = true;
1068 let Some(carried) = carried else { continue };
1069 let next = order
1073 .iter()
1074 .skip(index + 1)
1075 .find(|name| {
1076 document
1077 .get(name)
1078 .and_then(toml_edit::Item::as_table)
1079 .is_some_and(|table| !table.is_implicit())
1080 })
1081 .cloned();
1082 if let Some(next) = next
1083 && let Some(table) = document
1084 .get_mut(&next)
1085 .and_then(toml_edit::Item::as_table_mut)
1086 {
1087 let existing = table
1088 .decor()
1089 .prefix()
1090 .and_then(toml_edit::RawString::as_str)
1091 .unwrap_or_default()
1092 .trim_start_matches('\n')
1093 .to_owned();
1094 table
1095 .decor_mut()
1096 .set_prefix(format!("\n{carried}{existing}"));
1097 } else {
1098 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1099 if !trailing.is_empty() && !trailing.ends_with('\n') {
1100 trailing.push('\n');
1101 }
1102 trailing.push_str(&carried);
1103 document.set_trailing(trailing);
1104 }
1105 }
1106 changed
1107}
1108
1109fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1116 let mut lines = String::new();
1117 for raw in [decor.prefix(), decor.suffix()] {
1118 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1119 continue;
1120 };
1121 for line in text
1122 .lines()
1123 .map(str::trim)
1124 .filter(|line| line.starts_with('#'))
1125 .filter(|line| !is_template_comment(line))
1126 {
1127 lines.push_str(line);
1128 lines.push('\n');
1129 }
1130 }
1131 (!lines.is_empty()).then_some(lines)
1132}
1133
1134fn is_template_comment(line: &str) -> bool {
1143 let rest = line.trim_start_matches('#').trim_start();
1144 ["P:", "N:", "F:"]
1145 .iter()
1146 .any(|marker| rest.starts_with(marker))
1147}
1148
1149pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1157 let first = document
1158 .as_table()
1159 .iter()
1160 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1161 .map(|(name, _)| name.to_owned());
1162 if let Some(first) = first
1163 && let Some(table) = document
1164 .get_mut(&first)
1165 .and_then(toml_edit::Item::as_table_mut)
1166 {
1167 let existing = table
1168 .decor()
1169 .prefix()
1170 .and_then(toml_edit::RawString::as_str)
1171 .unwrap_or_default()
1172 .trim_start_matches('\n')
1173 .to_owned();
1174 table
1175 .decor_mut()
1176 .set_prefix(format!("\n{carried}{existing}"));
1177 return;
1178 }
1179 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1180 if !trailing.is_empty() && !trailing.ends_with('\n') {
1181 trailing.push('\n');
1182 }
1183 trailing.push_str(carried);
1184 document.set_trailing(trailing);
1185}
1186
1187pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1193 let (name, _) = table.get_key_value(key)?;
1194 carried_comment(name.leaf_decor())
1195}
1196
1197pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1199 let Some(mut name) = table.key_mut(key) else {
1200 return;
1201 };
1202 let decor = name.leaf_decor_mut();
1203 let existing = decor
1204 .prefix()
1205 .and_then(toml_edit::RawString::as_str)
1206 .unwrap_or_default()
1207 .trim_start_matches('\n')
1208 .to_owned();
1209 decor.set_prefix(format!("\n{carried}{existing}"));
1210}
1211
1212#[must_use]
1214pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1215 known
1216 .iter()
1217 .min_by_key(|name| distance(unknown, name))
1218 .copied()
1219}
1220
1221pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1224 let carried = carried_comment(table.decor())?;
1225 table.decor_mut().set_prefix("\n");
1226 Some(carried)
1227}
1228
1229pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1240 let carried = table.get_key_value(key).and_then(|(name, item)| {
1241 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1242 if let Some(value) = item.as_value()
1243 && let Some(more) = carried_comment(value.decor())
1244 {
1245 lines.push_str(&more);
1246 }
1247 (!lines.is_empty()).then_some(lines)
1248 });
1249 let removed = table.remove(key).is_some();
1250 if let Some(carried) = carried {
1251 let existing = table
1252 .decor()
1253 .prefix()
1254 .and_then(toml_edit::RawString::as_str)
1255 .unwrap_or_default()
1256 .trim_start_matches('\n')
1257 .to_owned();
1258 table
1259 .decor_mut()
1260 .set_prefix(format!("\n{carried}{existing}"));
1261 }
1262 removed
1263}
1264
1265fn apply_key(
1268 document: &mut toml_edit::DocumentMut,
1269 key: &str,
1270 value: Option<toml_edit::Value>,
1271) -> Result<bool, RkError> {
1272 let segments: Vec<&str> = key.split('.').collect();
1273 let Some((last, parents)) = segments.split_last() else {
1276 return Err(invalid(format!("{key} names no key")));
1277 };
1278 let Some(mut value) = value else {
1279 let mut item = document.as_item_mut();
1280 for segment in parents {
1281 if item.get(segment).is_none() {
1282 return Ok(false);
1283 }
1284 item = &mut item[segment];
1285 }
1286 let removed = item
1287 .as_table_mut()
1288 .is_some_and(|table| take_comments(table, last));
1289 return Ok(removed);
1290 };
1291 let mut item = document.as_item_mut();
1292 for segment in parents {
1293 if item.get(segment).is_none() {
1294 let mut table = toml_edit::Table::new();
1295 table.set_implicit(true);
1296 item[segment] = toml_edit::Item::Table(table);
1297 }
1298 item = &mut item[segment];
1299 }
1300 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1301 if old.to_string().trim() == value.to_string().trim() {
1302 return Ok(false);
1303 }
1304 *value.decor_mut() = old.decor().clone();
1305 } else if let Some(comment) = migrate::template_comment(&segments) {
1306 value.decor_mut().set_suffix(comment);
1312 }
1313 item[last] = toml_edit::Item::Value(value);
1314 Ok(true)
1315}
1316
1317#[derive(Debug, Clone, serde::Serialize)]
1319pub struct Plan {
1320 pub action: &'static str,
1322 pub changes: Vec<String>,
1324 pub content: String,
1326}
1327
1328impl Plan {
1329 pub fn new(
1334 target: &Path,
1335 params: &crate::landing::Params,
1336 existing: Option<&Config>,
1337 record: Option<&crate::landing::manifest::Manifest>,
1338 ) -> Result<Self, RkError> {
1339 let text = existing
1340 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1341 .transpose()?;
1342 Self::compose(text.as_deref(), params, existing, record)
1343 }
1344
1345 pub fn compose(
1352 text: Option<&str>,
1353 params: &crate::landing::Params,
1354 existing: Option<&Config>,
1355 record: Option<&crate::landing::manifest::Manifest>,
1356 ) -> Result<Self, RkError> {
1357 let mut resolved = existing.cloned().unwrap_or_default();
1358 resolved.schema_version = SCHEMA_VERSION;
1359 params.repo().clone_into(&mut resolved.project.repo);
1360 resolved.profile = Profile {
1361 technologies: Some(params.technologies().to_vec()),
1362 forge: Some(params.forge().unwrap_or_default().to_owned()),
1363 release: Release {
1364 mode: Some(params.release_mode()),
1365 driver: params.driver().map(str::to_owned),
1366 style: params.style(),
1367 line_prefix: params.profile().release.line_prefix.clone(),
1368 },
1369 };
1370 resolved.git = Git {
1371 trunk: Some(params.trunk().to_owned()),
1372 checkout_mode: Some(params.checkout_mode()),
1373 integration: Some(params.integration()),
1374 };
1375 resolved.protection = protection_for(
1376 &resolved.protection,
1377 params.integration(),
1378 existing.is_none(),
1379 );
1380 resolved.capabilities = Capabilities {
1381 nix_packaging: Some(params.nix_packaging()),
1382 reporting_policy: Some(params.reporting_policy()),
1383 scorecard: Some(params.scorecard()),
1384 code_scanning: Some(
1385 params
1386 .code_scanning()
1387 .map_or("off", crate::landing::Provider::as_str)
1388 .to_owned(),
1389 ),
1390 };
1391 resolved.security.contact = Some(params.security_contact().to_owned());
1392 resolved.security.response = Some(params.security_response().to_owned());
1393 let content = if let Some(text) = text.filter(|_| existing.is_some()) {
1394 rewrite_all(text, parameter_values(&resolved))?
1395 } else {
1396 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
1397 };
1398 parse(&content)?;
1399 Ok(Self {
1400 action: if existing.is_some() {
1401 "updated"
1402 } else {
1403 "added"
1404 },
1405 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1406 content,
1407 })
1408 }
1409
1410 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1415 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
1416 Ok(())
1417 }
1418}
1419
1420fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1422 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1423 "project.repo",
1424 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1425 )];
1426 if let Some(list) = &config.profile.technologies {
1427 values.push(("profile.technologies", Some(array(list))));
1428 }
1429 if let Some(forge) = config.profile.forge.clone() {
1430 values.push(("profile.forge", Some(forge.into())));
1431 }
1432 if let Some(mode) = config.profile.release.mode {
1433 values.push(("profile.release.mode", Some(mode.as_str().into())));
1434 values.push((
1435 "profile.release.driver",
1436 config
1437 .profile
1438 .release
1439 .driver
1440 .clone()
1441 .map(toml_edit::Value::from),
1442 ));
1443 values.push((
1444 "profile.release.style",
1445 config
1446 .profile
1447 .release
1448 .style
1449 .map(|style| style.as_str().into()),
1450 ));
1451 values.push((
1452 "profile.release.line_prefix",
1453 config
1454 .profile
1455 .release
1456 .line_prefix
1457 .clone()
1458 .map(toml_edit::Value::from),
1459 ));
1460 }
1461 if let Some(trunk) = config.git.trunk.clone() {
1462 values.push(("git.trunk", Some(trunk.into())));
1463 }
1464 if let Some(mode) = config.git.checkout_mode {
1465 values.push(("git.checkout_mode", Some(mode.as_str().into())));
1466 }
1467 if let Some(mode) = config.git.integration {
1468 values.push(("git.integration", Some(mode.as_str().into())));
1469 let mut rules = toml_edit::Array::new();
1473 for rule in &config.protection.owned_trunk_rules {
1474 rules.push(rule.as_str());
1475 }
1476 values.push((
1477 "protection.owned_trunk_rules",
1478 Some(toml_edit::Value::Array(rules)),
1479 ));
1480 values.push((
1481 "protection.gitlab.push_access_level",
1482 Some(config.protection.gitlab.push_access_level.into()),
1483 ));
1484 }
1485 if let Some(value) = config.capabilities.nix_packaging {
1486 values.push(("capabilities.nix_packaging", Some(value.into())));
1487 }
1488 if let Some(value) = config.capabilities.reporting_policy {
1489 values.push(("capabilities.reporting_policy", Some(value.into())));
1490 }
1491 if let Some(value) = config.capabilities.scorecard {
1492 values.push(("capabilities.scorecard", Some(value.into())));
1493 }
1494 if let Some(value) = config.capabilities.code_scanning.clone() {
1495 values.push(("capabilities.code_scanning", Some(value.into())));
1496 }
1497 if let Some(value) = config.security.contact.clone() {
1500 values.push(("security.contact", Some(value.into())));
1501 }
1502 if let Some(value) = config.security.response.clone() {
1503 values.push(("security.response", Some(value.into())));
1504 }
1505 values
1506}
1507
1508#[must_use]
1510pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
1511 let recorded = {
1512 let params = crate::landing::Params::from_record(record);
1513 Plan::compose(None, ¶ms, None, None)
1514 .ok()
1515 .and_then(|plan| parse(&plan.content).ok())
1516 };
1517 let Some(recorded) = recorded else {
1518 return Vec::new();
1519 };
1520 let render = |value: &Option<toml_edit::Value>| {
1521 value.as_ref().map_or_else(
1522 || "<absent>".to_owned(),
1523 |value| value.to_string().trim().to_owned(),
1524 )
1525 };
1526 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
1527 .iter()
1528 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1529 .map(|(key, value)| (*key, render(value)))
1530 .collect();
1531 parameter_values(config)
1532 .into_iter()
1533 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1534 .map(|(key, value)| (key, render(&value)))
1535 .filter(|(key, value)| {
1536 baseline
1537 .iter()
1538 .any(|(other, old)| key == other && value != old)
1539 })
1540 .map(|(key, _)| key.to_owned())
1541 .collect()
1542}
1543
1544#[must_use]
1552fn local_protection() -> Protection {
1553 const MAINTAINER: i64 = 40;
1555 let mut policy = Protection::default();
1556 policy
1557 .owned_trunk_rules
1558 .retain(|rule| rule != "pull_request" && rule != "required_status_checks");
1559 policy.gitlab.push_access_level = MAINTAINER;
1560 policy
1561}
1562
1563#[must_use]
1584fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
1585 let forge = Protection::default();
1586 let local = local_protection();
1587 let pair = |policy: &Protection| {
1588 (
1589 policy.owned_trunk_rules.clone(),
1590 policy.gitlab.push_access_level,
1591 )
1592 };
1593 let taken = fresh || pair(held) == pair(&forge) || pair(held) == pair(&local);
1594 if !taken {
1595 return held.clone();
1596 }
1597 let mut next = held.clone();
1598 let source = match integration {
1599 Integration::Forge => forge,
1600 Integration::Local => local,
1601 };
1602 next.owned_trunk_rules = source.owned_trunk_rules;
1603 next.gitlab.push_access_level = source.gitlab.push_access_level;
1604 next
1605}
1606
1607pub fn trunk_of(target: &Path) -> Result<String, RkError> {
1612 Ok(load(target)?
1613 .and_then(|config| config.git.trunk)
1614 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
1615}
1616
1617pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
1622 Ok(load(target)?
1623 .and_then(|config| config.profile.release.line_prefix)
1624 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
1625}
1626
1627#[cfg(test)]
1628mod tests {
1629 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
1630 use crate::landing::{CheckoutMode, Integration, Style};
1631 use crate::profile::ReleaseMode;
1632
1633 fn resolved_defaults() -> Config {
1636 Config {
1637 project: super::Project {
1638 repo: "acme/widget".into(),
1639 },
1640 profile: super::Profile {
1641 technologies: Some(vec!["rust".into()]),
1642 forge: Some("github".into()),
1643 release: super::Release {
1644 mode: Some(ReleaseMode::Automatic),
1645 driver: Some("rust".into()),
1646 style: Some(Style::Trunk),
1647 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
1648 },
1649 },
1650 git: super::Git {
1651 trunk: Some(super::TRUNK_DEFAULT.into()),
1652 checkout_mode: Some(CheckoutMode::LinkedWorktree),
1653 integration: Some(Integration::Local),
1654 },
1655 capabilities: super::Capabilities {
1656 nix_packaging: Some(false),
1657 reporting_policy: Some(true),
1658 scorecard: Some(false),
1659 code_scanning: Some("off".to_owned()),
1660 },
1661 security: super::Security {
1664 contact: Some(String::new()),
1665 response: Some(super::RESPONSE_DEFAULT.into()),
1666 ..super::Security::default()
1667 },
1668 protection: super::Protection {
1671 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
1672 ..super::Protection::default()
1673 },
1674 ..Config::default()
1675 }
1676 }
1677
1678 #[test]
1679 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
1680 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
1681 let explicit = parse(
1682 "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",
1683 )
1684 .expect("explicit defaults parse");
1685 assert_eq!(omitted.git, super::Git::default());
1686 assert_eq!(omitted.capabilities, super::Capabilities::default());
1687 assert_eq!(
1688 explicit.git.checkout_mode,
1689 Some(CheckoutMode::LinkedWorktree)
1690 );
1691 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
1692 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
1693 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
1694 assert_eq!(explicit.capabilities.scorecard, Some(false));
1695 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
1696 assert_ne!(omitted, explicit);
1697 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
1699 .expect("the older spelling reads");
1700 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
1701 }
1702
1703 #[test]
1707 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
1708 let dir = tempfile::tempdir().expect("a tempdir");
1709 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
1710 std::fs::write(
1711 dir.path().join(CONFIG_PATH),
1712 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
1713 )
1714 .expect("the config writes");
1715 let held = load(dir.path())
1716 .expect("the config reads")
1717 .expect("it is present");
1718 assert_eq!(held.setup.bot.app_id, "123");
1719 assert_eq!(
1720 held.setup.bot.installation_id,
1721 Some(0),
1722 "the key parses; nothing reads it"
1723 );
1724 }
1725
1726 #[test]
1728 fn a_schema_1_config_migrates_into_its_domains() {
1729 let held = parse(
1730 "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",
1731 )
1732 .expect("a schema 1 file reads");
1733 assert_eq!(held.schema_version, 2);
1734 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
1735 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
1736 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
1737 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
1738 assert_eq!(held.profile.release.style, Some(Style::Lines));
1739 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
1740 assert_eq!(held.git.trunk.as_deref(), Some("main"));
1741 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
1742 assert_eq!(held.capabilities.nix_packaging, Some(false));
1743 assert_eq!(held.capabilities.reporting_policy, Some(true));
1744 }
1745
1746 #[test]
1748 fn every_invalid_release_state_names_its_key() {
1749 for (text, key) in [
1750 (
1751 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
1752 "profile.release.style",
1753 ),
1754 (
1755 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
1756 "profile.release.driver",
1757 ),
1758 (
1759 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
1760 "profile.release.line_prefix",
1761 ),
1762 (
1763 "[profile]\ntechnologies = ['rust', 'rust']\n",
1764 "profile.technologies",
1765 ),
1766 (
1767 "[profile]\ntechnologies = ['Rust!']\n",
1768 "profile.technologies",
1769 ),
1770 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
1771 (
1772 "[profile.release]\nmode = 'manual'\n",
1773 "profile.release.mode",
1774 ),
1775 ] {
1776 let error = parse(&format!("schema_version = 2\n{text}"))
1777 .expect_err("an invalid release state refuses")
1778 .to_string();
1779 assert!(error.contains(key), "{key}: {error}");
1780 }
1781 }
1782
1783 #[test]
1787 fn a_newly_written_key_takes_the_templates_comment() {
1788 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
1789 let next = super::rewrite_text(
1790 text,
1791 "git.integration",
1792 Some(toml_edit::Value::from("local")),
1793 )
1794 .expect("the key writes");
1795 assert!(
1796 next.contains("integration = \"local\" # P: local or forge"),
1797 "{next}"
1798 );
1799 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
1801 .expect("the key writes");
1802 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
1803 }
1804
1805 #[test]
1806 fn the_landed_config_template_round_trips() {
1807 let dir = tempfile::tempdir().expect("a target exists");
1808 let mut config = Config::default();
1809 config.project.repo = "acme/nested/widget".into();
1810 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
1811 config.profile.forge = Some("gitlab".into());
1812 config.profile.release = super::Release {
1813 mode: Some(ReleaseMode::Automatic),
1814 driver: Some("bash".into()),
1815 style: Some(Style::Lines),
1816 line_prefix: Some("stable/".into()),
1817 };
1818 config.git.trunk = Some("main".into());
1819 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
1820 config.git.integration = Some(Integration::Forge);
1821 config.capabilities.nix_packaging = Some(true);
1822 config.capabilities.reporting_policy = Some(false);
1823 config.capabilities.scorecard = Some(true);
1824 config.capabilities.code_scanning = Some("semgrep".to_owned());
1825 config.security.advisories =
1829 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
1830 config.security.contact = Some("security team, room 3 \"the vault\"".into());
1831 config.security.response = Some("14 business days".into());
1832 config.setup.required_check = "build / test".into();
1833 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
1834 config.setup.release_lines = true;
1835 config.setup.excluded_steps = [
1836 (
1837 "package-check".to_owned(),
1838 "nothing is published".to_owned(),
1839 ),
1840 (
1841 "protect-trunk".to_owned(),
1842 "this project merges \"locally\"".to_owned(),
1843 ),
1844 ]
1845 .into_iter()
1846 .collect();
1847 config.setup.bot.app_id = "123".into();
1848 config.protection.trunk_ruleset = Some("primary".into());
1849 config.protection.tag_ruleset = "versions".into();
1850 config.protection.lines_ruleset = "maintenance".into();
1851 config.protection.title_check = "intent".into();
1852 config.protection.tag_pattern = "refs/tags/*".into();
1853 config
1854 .protection
1855 .owned_trunk_rules
1856 .push("required_signatures".into());
1857 config.protection.required_approving_review_count = 2;
1858 config.protection.dismiss_stale_reviews_on_push = true;
1859 config.protection.require_code_owner_review = true;
1860 config.protection.require_last_push_approval = true;
1861 config.protection.gitlab.squash_commit_template =
1862 "%{title}\n\nContext: %{description}".into();
1863 config.protection.gitlab.merge_access_level = 40;
1864 let mut release_less = resolved_defaults();
1867 release_less.project.repo = String::new();
1868 release_less.profile.technologies = Some(Vec::new());
1869 release_less.profile.forge = Some(String::new());
1870 release_less.profile.release = super::Release {
1871 mode: Some(ReleaseMode::None),
1872 driver: None,
1873 style: None,
1874 line_prefix: None,
1875 };
1876 release_less.capabilities.reporting_policy = Some(false);
1877 for expected in [resolved_defaults(), config, release_less] {
1878 write(dir.path(), &expected).expect("the template renders");
1879 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
1880 let text =
1881 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1882 assert!(text.contains("# P: every technology"));
1883 assert!(text.contains("# F: invariant"));
1884 }
1885 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1886 assert!(
1887 !text.contains("style ="),
1888 "a none release writes no style: {text}"
1889 );
1890 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
1891 }
1892
1893 #[test]
1894 fn a_config_with_an_unknown_key_refuses_by_name() {
1895 for (table, typo, nearest) in [
1896 ("", "schemax_version", "schema_version"),
1897 ("git", "trunkx", "trunk"),
1898 ("profile.release", "stile", "style"),
1899 ("capabilities", "nix_packagingg", "nix_packaging"),
1900 ("security", "contactx", "contact"),
1901 ("setup", "required_checkx", "required_check"),
1902 ("setup.bot", "app_i", "app_id"),
1903 ("protection", "trunk_rulesett", "trunk_ruleset"),
1904 (
1905 "protection.github",
1906 "squash_body_sourcex",
1907 "squash_body_source",
1908 ),
1909 ("protection.gitlab", "squash_optionx", "squash_option"),
1910 ] {
1911 let header = if table.is_empty() {
1912 String::new()
1913 } else {
1914 format!("[{table}]\n")
1915 };
1916 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
1917 let error = parse(&text).expect_err("unknown keys refuse").to_string();
1918 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
1919 assert!(error.contains(expected), "{error}");
1920 }
1921 }
1922 }
1923
1924 #[test]
1928 fn an_exclusion_names_a_real_step_and_states_why() {
1929 for (text, expected) in [
1930 (
1931 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
1932 vec!["protect-trunkk", "nearest known step: protect-trunk"],
1933 ),
1934 (
1935 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
1936 vec!["protect-trunk", "no reason"],
1937 ),
1938 ] {
1939 let error = parse(&format!("schema_version = 2\n{text}"))
1940 .expect_err("the exclusion refuses")
1941 .to_string();
1942 for want in expected {
1943 assert!(error.contains(want), "{error}");
1944 }
1945 }
1946 let held = parse(
1947 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
1948 )
1949 .expect("a named step with a reason parses");
1950 assert_eq!(
1951 held.setup
1952 .excluded_steps
1953 .get("protect-trunk")
1954 .map(String::as_str),
1955 Some("we merge locally")
1956 );
1957 }
1958
1959 #[test]
1963 fn an_exclusion_does_not_lift_a_floor() {
1964 let error = parse(
1965 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
1966 )
1967 .expect_err("the floor binds an excluded step's keys too")
1968 .to_string();
1969 assert!(
1970 error.contains("protection.allowed_merge_methods"),
1971 "{error}"
1972 );
1973 }
1974
1975 #[test]
1976 fn a_config_at_an_unknown_schema_refuses() {
1977 for text in ["schema_version = 999", "schema_version = '2'", ""] {
1978 let error = parse(text)
1979 .expect_err("a schema must be declared and known")
1980 .to_string();
1981 assert!(
1982 error.contains(CONFIG_PATH) && error.contains("schema_version"),
1983 "{error}"
1984 );
1985 }
1986 }
1987
1988 #[test]
1989 fn an_unparsable_config_refuses_naming_the_position() {
1990 let error = parse("schema_version = 2\n[project\n")
1991 .expect_err("bad TOML refuses")
1992 .to_string();
1993 for expected in [CONFIG_PATH, "line 2", "column"] {
1994 assert!(error.contains(expected), "{error}");
1995 }
1996 }
1997
1998 #[test]
1999 fn an_absent_config_reads_as_none() {
2000 let dir = tempfile::tempdir().expect("a target exists");
2001 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2002 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2003 }
2004
2005 #[test]
2006 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2007 let dir = tempfile::tempdir().expect("a target exists");
2008 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2009 std::fs::write(
2010 dir.path().join(CONFIG_PATH),
2011 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2012 )
2013 .expect("a config exists");
2014 let error =
2015 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2016 assert_eq!(error.exit_code(), 73);
2017 assert!(
2018 error
2019 .to_string()
2020 .contains("protection.strict_required_status_checks")
2021 );
2022 }
2023
2024 #[test]
2025 fn rewrite_key_preserves_comments() {
2026 let dir = tempfile::tempdir().expect("a target exists");
2027 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2028 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";
2029 let path = dir.path().join(CONFIG_PATH);
2030 std::fs::write(&path, original).expect("a config exists");
2031 rewrite_key(dir.path(), "profile.release.style", "lines".into())
2032 .expect("the style writes back");
2033 let text = std::fs::read_to_string(&path).expect("the text reads");
2034 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2035 assert_eq!(
2036 load(dir.path())
2037 .expect("the config reads")
2038 .expect("present")
2039 .profile
2040 .release
2041 .style,
2042 Some(Style::Lines)
2043 );
2044 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2045 .expect("an omitted table can be added");
2046 assert_eq!(
2047 load(dir.path())
2048 .expect("reads")
2049 .expect("present")
2050 .project
2051 .repo,
2052 "acme/widget"
2053 );
2054 rewrite_key(
2055 dir.path(),
2056 "security.contact",
2057 "security@acme.example".into(),
2058 )
2059 .expect("the contact is a landing parameter");
2060 rewrite_key(dir.path(), "security.response", "14 days".into())
2061 .expect("the response is a landing parameter");
2062 let held = load(dir.path()).expect("reads").expect("present");
2063 assert_eq!(
2064 held.security.contact.as_deref(),
2065 Some("security@acme.example")
2066 );
2067 assert_eq!(held.security.response.as_deref(), Some("14 days"));
2068 let text = std::fs::read_to_string(&path).expect("the text reads");
2069 assert!(text.contains("# keep me"), "the comment survives: {text}");
2070 let before = std::fs::read(&path).expect("the bytes read");
2071 for (key, value) in [
2072 ("security.advisories", "acme/private"),
2073 ("security.response", "90d"),
2074 ("profile.release.style", "unknown"),
2075 ] {
2076 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2077 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2078 }
2079 }
2080
2081 #[test]
2084 fn the_security_answers_are_held_to_their_grammar() {
2085 for value in ["team@acme.example", " https://acme.example/report ", ""] {
2086 super::canonical_contact(value).expect("a control-free line is a contact");
2087 }
2088 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2089 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2090 assert!(refusal.contains("security.contact"), "{refusal}");
2091 }
2092 assert_eq!(
2093 super::canonical_contact(" team@acme.example "),
2094 Ok("team@acme.example".to_owned()),
2095 "surrounding whitespace is trimmed"
2096 );
2097 for value in [
2098 "best-effort",
2099 "1 day",
2100 "2 days",
2101 "14 days",
2102 "1 business day",
2103 "14 business days",
2104 ] {
2105 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2106 }
2107 assert_eq!(
2108 super::canonical_response(""),
2109 Ok(super::RESPONSE_DEFAULT.to_owned()),
2110 "an empty answer reads as the compiled default"
2111 );
2112 for value in [
2113 "0 days",
2114 "1 days",
2115 "2 day",
2116 "+2 days",
2117 "02 days",
2118 "4294967296 days",
2119 "90d",
2120 "two days",
2121 "we answer quickly",
2122 "2 weeks",
2123 ] {
2124 let refusal =
2125 super::canonical_response(value).expect_err("an unstateable window refuses");
2126 assert!(refusal.contains("security.response"), "{value}: {refusal}");
2127 assert!(refusal.contains("business days"), "{value}: {refusal}");
2128 }
2129 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2130 .expect_err("the reader refuses it too")
2131 .to_string();
2132 assert!(refusal.contains("security.response"), "{refusal}");
2133 }
2134
2135 #[test]
2140 fn a_pruned_header_leaves_no_comment_behind() {
2141 let cases = [
2142 (
2143 "a middle table, comment above",
2144 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2145 ),
2146 (
2147 "a middle table, comment inline",
2148 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2149 ),
2150 (
2151 "the last table, comment above",
2152 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
2153 ),
2154 (
2155 "the last table, comment inline",
2156 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
2157 ),
2158 ];
2159 for (case, text) in cases {
2160 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2161 assert!(!next.contains("repo ="), "{case}: {next}");
2162 assert!(
2163 !next.contains("[project]"),
2164 "{case}: a table every one of whose keys dropped goes with them: {next}"
2165 );
2166 assert!(
2167 next.contains("# the operator's note"),
2168 "{case}: the comment the header carried survives: {next}"
2169 );
2170 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2171 }
2172 }
2173
2174 #[test]
2180 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2181 let text = concat!(
2182 "schema_version = 2\n\n[project]\n",
2183 "# the operator's note\n",
2184 "repo = \"acme/widget\" # P: project path on the forge\n\n",
2185 "[git]\ntrunk = \"main\"\n"
2186 );
2187 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2188 assert!(!next.contains("repo ="), "{next}");
2189 assert!(!next.contains("[project]"), "{next}");
2190 assert!(
2191 next.contains("# the operator's note"),
2192 "authored text survives: {next}"
2193 );
2194 assert!(
2195 !next.contains("# P:"),
2196 "the template's comment describes a key that is gone: {next}"
2197 );
2198 parse(&next).expect("the result parses");
2199
2200 let text = concat!(
2202 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2203 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2204 "# why this project names its own prefix\n",
2205 "line_prefix = \"stable/\"\n"
2206 );
2207 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2208 .expect("the key removes");
2209 assert!(!next.contains("line_prefix ="), "{next}");
2210 assert!(next.contains("[profile.release]"), "{next}");
2211 assert!(
2212 next.contains("# why this project names its own prefix"),
2213 "authored text survives: {next}"
2214 );
2215 }
2216}