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, 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}
112
113#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
115#[serde(deny_unknown_fields, default)]
116pub struct Capabilities {
117 pub nix_packaging: Option<bool>,
119 pub reporting_policy: Option<bool>,
121 pub scorecard: Option<bool>,
123 pub code_scanning: Option<String>,
127}
128
129pub const TRUNK_DEFAULT: &str = "master";
131
132pub const LINE_PREFIX_DEFAULT: &str = "release/";
134
135#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
137#[serde(deny_unknown_fields, default)]
138pub struct Security {
139 pub advisories: String,
141 pub contact: Option<String>,
146 pub response: Option<String>,
149}
150
151pub const RESPONSE_DEFAULT: &str = "best-effort";
154
155pub fn canonical_contact(raw: &str) -> Result<String, String> {
165 let trimmed = raw.trim();
166 if trimmed.chars().any(char::is_control) {
167 return Err(format!(
168 "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:?}"
169 ));
170 }
171 Ok(trimmed.to_owned())
172}
173
174pub fn canonical_response(raw: &str) -> Result<String, String> {
186 let trimmed = raw.trim();
187 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
188 return Ok(RESPONSE_DEFAULT.to_owned());
189 }
190 let refusal = || {
191 format!(
192 "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:?}"
193 )
194 };
195 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
196 let plural = match unit {
197 "day" | "business day" => false,
198 "days" | "business days" => true,
199 _ => return Err(refusal()),
200 };
201 let number: u32 = count.parse().map_err(|_| refusal())?;
202 if count != number.to_string() || (number > 1) != plural {
205 return Err(refusal());
206 }
207 Ok(trimmed.to_owned())
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
212#[serde(deny_unknown_fields, default)]
213pub struct Setup {
214 pub required_check: String,
216 pub retired_branches: Vec<String>,
218 pub release_lines: bool,
220 pub excluded_steps: BTreeMap<String, String>,
225 pub bot: Bot,
227}
228
229impl Default for Setup {
230 fn default() -> Self {
231 Self {
232 required_check: String::new(),
233 retired_branches: vec!["main".into(), "develop".into()],
234 release_lines: false,
235 excluded_steps: BTreeMap::new(),
236 bot: Bot::default(),
237 }
238 }
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
248#[serde(deny_unknown_fields, default)]
249pub struct Bot {
250 pub app_id: String,
252 #[serde(default, skip_serializing)]
257 pub installation_id: Option<i64>,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
262#[serde(deny_unknown_fields, default)]
263#[allow(
264 clippy::struct_excessive_bools,
265 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
266)]
267pub struct Protection {
268 pub trunk_ruleset: Option<String>,
272 pub tag_ruleset: String,
274 pub lines_ruleset: String,
276 pub title_check: String,
278 pub tag_pattern: String,
280 pub bypass_actors: Vec<String>,
282 pub allowed_merge_methods: Vec<String>,
284 pub strict_required_status_checks: bool,
286 pub owned_trunk_rules: Vec<String>,
288 pub required_approving_review_count: i64,
290 pub dismiss_stale_reviews_on_push: bool,
292 pub require_code_owner_review: bool,
294 pub require_last_push_approval: bool,
296 pub github: Github,
298 pub gitlab: Gitlab,
300}
301
302impl Default for Protection {
303 fn default() -> Self {
304 Self {
305 trunk_ruleset: None,
306 tag_ruleset: "release-tags".into(),
307 lines_ruleset: "release-lines".into(),
308 title_check: "pr-title".into(),
309 tag_pattern: "refs/tags/v*".into(),
310 bypass_actors: Vec::new(),
311 allowed_merge_methods: vec!["squash".into()],
312 strict_required_status_checks: true,
313 owned_trunk_rules: vec![
314 "deletion".into(),
315 "non_fast_forward".into(),
316 "pull_request".into(),
317 "required_status_checks".into(),
318 ],
319 required_approving_review_count: 0,
320 dismiss_stale_reviews_on_push: false,
321 require_code_owner_review: false,
322 require_last_push_approval: false,
323 github: Github::default(),
324 gitlab: Gitlab::default(),
325 }
326 }
327}
328
329impl Protection {
330 #[must_use]
333 pub fn trunk_ruleset(&self, trunk: &str) -> String {
334 self.trunk_ruleset
335 .clone()
336 .unwrap_or_else(|| format!("{trunk}-protection"))
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
342#[serde(deny_unknown_fields, default)]
343pub struct Github {
344 pub squash_title_source: String,
346 pub squash_body_source: String,
348}
349
350impl Default for Github {
351 fn default() -> Self {
352 Self {
353 squash_title_source: "PR_TITLE".into(),
354 squash_body_source: "PR_BODY".into(),
355 }
356 }
357}
358
359#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
361#[serde(deny_unknown_fields, default)]
362pub struct Gitlab {
363 pub merge_method: String,
365 pub squash_option: String,
367 pub squash_commit_template: String,
369 pub push_access_level: i64,
371 pub merge_access_level: i64,
373}
374
375impl Default for Gitlab {
376 fn default() -> Self {
377 Self {
378 merge_method: "ff".into(),
379 squash_option: "always".into(),
380 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
381 .trim_end_matches('\n')
382 .to_owned(),
383 push_access_level: 0,
384 merge_access_level: 40,
385 }
386 }
387}
388
389pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
394 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
395 Ok(text) => text,
396 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
397 Err(error) => return Err(error.into()),
398 };
399 parse(&text).map(Some)
400}
401
402fn current_text(text: &str) -> Result<String, RkError> {
405 let raw: toml::Value =
406 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
407 match raw.get("schema_version").and_then(toml::Value::as_integer) {
408 Some(1) => migrate::to_schema_2(text),
409 _ => Ok(text.to_owned()),
410 }
411}
412
413fn parse(text: &str) -> Result<Config, RkError> {
414 let text = current_text(text)?;
415 let raw: toml::Value =
416 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
417 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
418 return Err(invalid(format!(
419 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
420 )));
421 }
422 if let Some(mode) = raw
426 .get("profile")
427 .and_then(|profile| profile.get("release"))
428 .and_then(|release| release.get("mode"))
429 {
430 let named = mode.as_str().ok_or_else(|| {
431 invalid("profile.release.mode must be a string: automatic, external, or none")
432 })?;
433 crate::profile::ReleaseMode::parse(named)
434 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
435 }
436 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
437 let mut message = error.to_string();
438 if let Some(rest) = error.message().strip_prefix("unknown field `") {
439 let names: Vec<_> = rest.split('`').collect();
440 if let Some(unknown) = names.first()
441 && let Some(nearest) = names
442 .iter()
443 .skip(2)
444 .step_by(2)
445 .min_by_key(|name| distance(unknown, name))
446 {
447 let _ = write!(message, "; nearest known key: {nearest}");
448 }
449 }
450 invalid(message)
451 })?;
452 if let Some(technologies) = &config.profile.technologies {
453 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
454 }
455 if let Some(forge) = &config.profile.forge
456 && !forge.is_empty()
457 {
458 crate::profile::canonical_category(forge)
459 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
460 }
461 if let Some(driver) = &config.profile.release.driver {
462 crate::profile::canonical_category(driver)
463 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
464 }
465 if matches!(
466 config.profile.release.mode,
467 Some(ReleaseMode::External | ReleaseMode::None)
468 ) {
469 let mode = config
470 .profile
471 .release
472 .mode
473 .map_or("none", ReleaseMode::as_str);
474 for (key, present) in [
475 (
476 "profile.release.driver",
477 config.profile.release.driver.is_some(),
478 ),
479 (
480 "profile.release.style",
481 config.profile.release.style.is_some(),
482 ),
483 (
484 "profile.release.line_prefix",
485 config.profile.release.line_prefix.is_some(),
486 ),
487 ] {
488 if present {
489 return Err(invalid(format!(
490 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
491 )));
492 }
493 }
494 }
495 if let Some(contact) = &config.security.contact {
496 canonical_contact(contact).map_err(invalid)?;
497 }
498 if let Some(response) = &config.security.response {
499 canonical_response(response).map_err(invalid)?;
500 }
501 exclusions(&config.setup.excluded_steps)?;
502 floors::check(&config)?;
503 Ok(config)
504}
505
506fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
513 for (name, reason) in excluded {
514 if crate::setup::steps::spec(name).is_none() {
515 let nearest = crate::setup::steps::STEPS
516 .iter()
517 .min_by_key(|step| distance(name, step.name))
518 .map_or("", |step| step.name);
519 return Err(invalid(format!(
520 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
521 )));
522 }
523 if reason.trim().is_empty() {
524 return Err(invalid(format!(
525 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
526 )));
527 }
528 }
529 Ok(())
530}
531
532pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
533 RkError::refusal(
534 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
535 .action(format!("edit {CONFIG_PATH} and retry"))
536 .target_state("nothing was written"),
537 )
538}
539
540fn distance(left: &str, right: &str) -> usize {
541 let mut row: Vec<_> = (0..=right.chars().count()).collect();
542 for (i, a) in left.chars().enumerate() {
543 let mut previous = row[0];
544 row[0] = i + 1;
545 for (j, b) in right.chars().enumerate() {
546 let old = row[j + 1];
547 row[j + 1] = (previous + usize::from(a != b))
548 .min(row[j] + 1)
549 .min(old + 1);
550 previous = old;
551 }
552 }
553 row.last().copied().unwrap_or(0)
554}
555
556pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
561 let bytes = render(config)?;
562 parse(&String::from_utf8_lossy(&bytes))?;
563 crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
564 Ok(())
565}
566
567fn array(values: &[String]) -> toml_edit::Value {
568 toml_edit::Value::Array(values.iter().collect())
569}
570
571fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
576 let mut table = toml_edit::InlineTable::new();
577 for (key, value) in values {
578 table.insert(key, value.clone().into());
579 }
580 toml_edit::Value::InlineTable(table)
581}
582
583#[allow(
588 clippy::too_many_lines,
589 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
590)]
591fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
592 let trunk = config
596 .git
597 .trunk
598 .clone()
599 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
600 let technologies = config
601 .profile
602 .technologies
603 .clone()
604 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
605 let forge = config
606 .profile
607 .forge
608 .clone()
609 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
610 let mode = config
611 .profile
612 .release
613 .mode
614 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
615 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
616 (
617 "RK_CONFIG_SCHEMA_VERSION",
618 Some(config.schema_version.into()),
619 ),
620 (
621 "RK_CONFIG_PROJECT_REPO",
622 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
623 ),
624 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
625 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
626 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
627 (
628 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
629 config
630 .profile
631 .release
632 .driver
633 .clone()
634 .map(toml_edit::Value::from),
635 ),
636 (
637 "RK_CONFIG_PROFILE_RELEASE_STYLE",
638 config
639 .profile
640 .release
641 .style
642 .map(|style| style.as_str().into()),
643 ),
644 (
645 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
646 config
647 .profile
648 .release
649 .line_prefix
650 .clone()
651 .map(toml_edit::Value::from),
652 ),
653 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
654 (
655 "RK_CONFIG_GIT_CHECKOUT_MODE",
656 Some(
657 config
658 .git
659 .checkout_mode
660 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
661 .as_str()
662 .into(),
663 ),
664 ),
665 (
666 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
667 Some(
668 config
669 .capabilities
670 .nix_packaging
671 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
672 .into(),
673 ),
674 ),
675 (
676 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
677 Some(
678 config
679 .capabilities
680 .reporting_policy
681 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
682 .into(),
683 ),
684 ),
685 (
686 "RK_CONFIG_CAPABILITIES_SCORECARD",
687 Some(
688 config
689 .capabilities
690 .scorecard
691 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
692 .into(),
693 ),
694 ),
695 (
696 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
697 Some(
698 config
699 .capabilities
700 .code_scanning
701 .clone()
702 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
703 .into(),
704 ),
705 ),
706 (
707 "RK_CONFIG_SECURITY_ADVISORIES",
708 Some(config.security.advisories.clone().into()),
709 ),
710 (
711 "RK_CONFIG_SECURITY_CONTACT",
712 Some(config.security.contact.clone().unwrap_or_default().into()),
713 ),
714 (
715 "RK_CONFIG_SECURITY_RESPONSE",
716 Some(
717 config
718 .security
719 .response
720 .clone()
721 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
722 .into(),
723 ),
724 ),
725 (
726 "RK_CONFIG_SETUP_REQUIRED_CHECK",
727 Some(config.setup.required_check.clone().into()),
728 ),
729 (
730 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
731 Some(array(&config.setup.retired_branches)),
732 ),
733 (
734 "RK_CONFIG_SETUP_RELEASE_LINES",
735 Some(config.setup.release_lines.into()),
736 ),
737 (
738 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
739 Some(inline(&config.setup.excluded_steps)),
740 ),
741 (
742 "RK_CONFIG_SETUP_BOT_APP_ID",
743 Some(config.setup.bot.app_id.clone().into()),
744 ),
745 ];
746 fields.extend(
747 protection_fields(&config.protection, trunk.as_str())
748 .into_iter()
749 .map(|(token, value)| (token, Some(value))),
750 );
751 Ok(fields)
752}
753
754fn render(config: &Config) -> Result<Vec<u8>, RkError> {
755 let fields = fields(config)?;
756 let template = crate::embedded::BLOCKS
757 .get_file("target-config.toml.in")
758 .and_then(include_dir::File::contents_utf8)
759 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
760 let lines: Vec<&str> = template.split_inclusive('\n').collect();
766 let dropped: Vec<bool> = lines
767 .iter()
768 .map(|line| {
769 matches!(
770 fields.iter().find(|(token, _)| line.contains(token)),
771 Some((_, None))
772 )
773 })
774 .collect();
775 let mut bytes = Vec::new();
776 let mut at = 0;
777 while at < lines.len() {
778 let line = lines[at];
779 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
780 let mut end = at + 1;
781 let mut keeps = false;
782 while end < lines.len()
783 && !(lines[end].trim_start().starts_with('[')
784 && lines[end].trim_end().ends_with(']'))
785 {
786 keeps |=
787 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
788 end += 1;
789 }
790 if !keeps {
791 while bytes.last() == Some(&b'\n')
793 && bytes.len() >= 2
794 && bytes[bytes.len() - 2] == b'\n'
795 {
796 bytes.pop();
797 }
798 at = end;
799 continue;
800 }
801 }
802 if !dropped[at] {
803 match fields.iter().find(|(token, _)| line.contains(token)) {
804 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
805 line.as_bytes(),
806 token.as_bytes(),
807 value.to_string().as_bytes(),
808 )),
809 Some((_, None)) => {}
810 None => bytes.extend_from_slice(line.as_bytes()),
811 }
812 }
813 at += 1;
814 }
815 Ok(bytes)
816}
817
818fn protection_fields(
819 protection: &Protection,
820 trunk: &str,
821) -> Vec<(&'static str, toml_edit::Value)> {
822 vec![
823 (
824 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
825 protection.trunk_ruleset(trunk).into(),
826 ),
827 (
828 "RK_CONFIG_PROTECTION_TAG_RULESET",
829 protection.tag_ruleset.clone().into(),
830 ),
831 (
832 "RK_CONFIG_PROTECTION_LINES_RULESET",
833 protection.lines_ruleset.clone().into(),
834 ),
835 (
836 "RK_CONFIG_PROTECTION_TITLE_CHECK",
837 protection.title_check.clone().into(),
838 ),
839 (
840 "RK_CONFIG_PROTECTION_TAG_PATTERN",
841 protection.tag_pattern.clone().into(),
842 ),
843 (
844 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
845 array(&protection.bypass_actors),
846 ),
847 (
848 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
849 array(&protection.allowed_merge_methods),
850 ),
851 (
852 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
853 protection.strict_required_status_checks.into(),
854 ),
855 (
856 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
857 array(&protection.owned_trunk_rules),
858 ),
859 (
860 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
861 protection.required_approving_review_count.into(),
862 ),
863 (
864 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
865 protection.dismiss_stale_reviews_on_push.into(),
866 ),
867 (
868 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
869 protection.require_code_owner_review.into(),
870 ),
871 (
872 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
873 protection.require_last_push_approval.into(),
874 ),
875 (
876 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
877 protection.github.squash_title_source.clone().into(),
878 ),
879 (
880 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
881 protection.github.squash_body_source.clone().into(),
882 ),
883 (
884 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
885 protection.gitlab.merge_method.clone().into(),
886 ),
887 (
888 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
889 protection.gitlab.squash_option.clone().into(),
890 ),
891 (
892 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
893 protection.gitlab.squash_commit_template.clone().into(),
894 ),
895 (
896 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
897 protection.gitlab.push_access_level.into(),
898 ),
899 (
900 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
901 protection.gitlab.merge_access_level.into(),
902 ),
903 ]
904}
905
906const PARAMETER_KEYS: [&str; 16] = [
908 "project.repo",
909 "profile.technologies",
910 "profile.forge",
911 "profile.release.mode",
912 "profile.release.driver",
913 "profile.release.style",
914 "profile.release.line_prefix",
915 "git.trunk",
916 "git.checkout_mode",
917 "capabilities.nix_packaging",
918 "capabilities.reporting_policy",
919 "capabilities.scorecard",
920 "capabilities.code_scanning",
921 "security.contact",
922 "security.response",
923 "schema_version",
924];
925
926pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
931 let path = target.join(CONFIG_PATH);
932 let text = std::fs::read_to_string(&path)?;
933 let next = rewrite_text(&text, key, Some(value))?;
934 crate::atomic::write(&path, next.as_bytes())?;
935 Ok(())
936}
937
938fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
941 rewrite_all(text, vec![(key, value)])
942}
943
944fn rewrite_all(
952 text: &str,
953 values: Vec<(&str, Option<toml_edit::Value>)>,
954) -> Result<String, RkError> {
955 for (key, _) in &values {
956 if !PARAMETER_KEYS.contains(key) {
957 return Err(invalid(format!("{key} is not a landing parameter")));
958 }
959 }
960 parse(text)?;
961 let text = current_text(text)?;
962 let mut document = text
963 .parse::<toml_edit::DocumentMut>()
964 .map_err(|error| invalid(error.to_string()))?;
965 let mut changed = false;
966 let mut emptied: Vec<&str> = Vec::new();
969 for (key, value) in values {
970 let removing = value.is_none();
971 let applied = apply_key(&mut document, key, value)?;
972 changed |= applied;
973 if applied
974 && removing
975 && let Some(parent) = key.split('.').next()
976 {
977 emptied.push(parent);
978 }
979 }
980 changed |= prune_empty_tables(&mut document, &emptied);
981 if !changed {
982 return Ok(text);
983 }
984 let next = document.to_string();
985 parse(&next)?;
986 Ok(next)
987}
988
989pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1008 let order: Vec<String> = document
1009 .as_table()
1010 .iter()
1011 .map(|(key, _)| key.to_owned())
1012 .collect();
1013 let mut changed = false;
1014 for (index, name) in order.iter().enumerate() {
1015 if !names.contains(&name.as_str())
1016 || !document
1017 .get(name)
1018 .and_then(toml_edit::Item::as_table)
1019 .is_some_and(toml_edit::Table::is_empty)
1020 {
1021 continue;
1022 }
1023 let carried = document
1024 .get(name)
1025 .and_then(toml_edit::Item::as_table)
1026 .and_then(|table| carried_comment(table.decor()));
1027 document.remove(name);
1028 changed = true;
1029 let Some(carried) = carried else { continue };
1030 let next = order
1034 .iter()
1035 .skip(index + 1)
1036 .find(|name| {
1037 document
1038 .get(name)
1039 .and_then(toml_edit::Item::as_table)
1040 .is_some_and(|table| !table.is_implicit())
1041 })
1042 .cloned();
1043 if let Some(next) = next
1044 && let Some(table) = document
1045 .get_mut(&next)
1046 .and_then(toml_edit::Item::as_table_mut)
1047 {
1048 let existing = table
1049 .decor()
1050 .prefix()
1051 .and_then(toml_edit::RawString::as_str)
1052 .unwrap_or_default()
1053 .trim_start_matches('\n')
1054 .to_owned();
1055 table
1056 .decor_mut()
1057 .set_prefix(format!("\n{carried}{existing}"));
1058 } else {
1059 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1060 if !trailing.is_empty() && !trailing.ends_with('\n') {
1061 trailing.push('\n');
1062 }
1063 trailing.push_str(&carried);
1064 document.set_trailing(trailing);
1065 }
1066 }
1067 changed
1068}
1069
1070fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1077 let mut lines = String::new();
1078 for raw in [decor.prefix(), decor.suffix()] {
1079 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1080 continue;
1081 };
1082 for line in text
1083 .lines()
1084 .map(str::trim)
1085 .filter(|line| line.starts_with('#'))
1086 .filter(|line| !is_template_comment(line))
1087 {
1088 lines.push_str(line);
1089 lines.push('\n');
1090 }
1091 }
1092 (!lines.is_empty()).then_some(lines)
1093}
1094
1095fn is_template_comment(line: &str) -> bool {
1104 let rest = line.trim_start_matches('#').trim_start();
1105 ["P:", "N:", "F:"]
1106 .iter()
1107 .any(|marker| rest.starts_with(marker))
1108}
1109
1110pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1118 let first = document
1119 .as_table()
1120 .iter()
1121 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1122 .map(|(name, _)| name.to_owned());
1123 if let Some(first) = first
1124 && let Some(table) = document
1125 .get_mut(&first)
1126 .and_then(toml_edit::Item::as_table_mut)
1127 {
1128 let existing = table
1129 .decor()
1130 .prefix()
1131 .and_then(toml_edit::RawString::as_str)
1132 .unwrap_or_default()
1133 .trim_start_matches('\n')
1134 .to_owned();
1135 table
1136 .decor_mut()
1137 .set_prefix(format!("\n{carried}{existing}"));
1138 return;
1139 }
1140 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1141 if !trailing.is_empty() && !trailing.ends_with('\n') {
1142 trailing.push('\n');
1143 }
1144 trailing.push_str(carried);
1145 document.set_trailing(trailing);
1146}
1147
1148pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1154 let (name, _) = table.get_key_value(key)?;
1155 carried_comment(name.leaf_decor())
1156}
1157
1158pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1160 let Some(mut name) = table.key_mut(key) else {
1161 return;
1162 };
1163 let decor = name.leaf_decor_mut();
1164 let existing = decor
1165 .prefix()
1166 .and_then(toml_edit::RawString::as_str)
1167 .unwrap_or_default()
1168 .trim_start_matches('\n')
1169 .to_owned();
1170 decor.set_prefix(format!("\n{carried}{existing}"));
1171}
1172
1173#[must_use]
1175pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1176 known
1177 .iter()
1178 .min_by_key(|name| distance(unknown, name))
1179 .copied()
1180}
1181
1182pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1185 let carried = carried_comment(table.decor())?;
1186 table.decor_mut().set_prefix("\n");
1187 Some(carried)
1188}
1189
1190pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1201 let carried = table.get_key_value(key).and_then(|(name, item)| {
1202 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1203 if let Some(value) = item.as_value()
1204 && let Some(more) = carried_comment(value.decor())
1205 {
1206 lines.push_str(&more);
1207 }
1208 (!lines.is_empty()).then_some(lines)
1209 });
1210 let removed = table.remove(key).is_some();
1211 if let Some(carried) = carried {
1212 let existing = table
1213 .decor()
1214 .prefix()
1215 .and_then(toml_edit::RawString::as_str)
1216 .unwrap_or_default()
1217 .trim_start_matches('\n')
1218 .to_owned();
1219 table
1220 .decor_mut()
1221 .set_prefix(format!("\n{carried}{existing}"));
1222 }
1223 removed
1224}
1225
1226fn apply_key(
1229 document: &mut toml_edit::DocumentMut,
1230 key: &str,
1231 value: Option<toml_edit::Value>,
1232) -> Result<bool, RkError> {
1233 let segments: Vec<&str> = key.split('.').collect();
1234 let Some((last, parents)) = segments.split_last() else {
1237 return Err(invalid(format!("{key} names no key")));
1238 };
1239 let Some(mut value) = value else {
1240 let mut item = document.as_item_mut();
1241 for segment in parents {
1242 if item.get(segment).is_none() {
1243 return Ok(false);
1244 }
1245 item = &mut item[segment];
1246 }
1247 let removed = item
1248 .as_table_mut()
1249 .is_some_and(|table| take_comments(table, last));
1250 return Ok(removed);
1251 };
1252 let mut item = document.as_item_mut();
1253 for segment in parents {
1254 if item.get(segment).is_none() {
1255 let mut table = toml_edit::Table::new();
1256 table.set_implicit(true);
1257 item[segment] = toml_edit::Item::Table(table);
1258 }
1259 item = &mut item[segment];
1260 }
1261 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1262 if old.to_string().trim() == value.to_string().trim() {
1263 return Ok(false);
1264 }
1265 *value.decor_mut() = old.decor().clone();
1266 }
1267 item[last] = toml_edit::Item::Value(value);
1268 Ok(true)
1269}
1270
1271#[derive(Debug, Clone, serde::Serialize)]
1273pub struct Plan {
1274 pub action: &'static str,
1276 pub changes: Vec<String>,
1278 pub content: String,
1280}
1281
1282impl Plan {
1283 pub fn new(
1288 target: &Path,
1289 params: &crate::landing::Params,
1290 existing: Option<&Config>,
1291 record: Option<&crate::landing::manifest::Manifest>,
1292 ) -> Result<Self, RkError> {
1293 let text = existing
1294 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1295 .transpose()?;
1296 Self::compose(text.as_deref(), params, existing, record)
1297 }
1298
1299 pub fn compose(
1306 text: Option<&str>,
1307 params: &crate::landing::Params,
1308 existing: Option<&Config>,
1309 record: Option<&crate::landing::manifest::Manifest>,
1310 ) -> Result<Self, RkError> {
1311 let mut resolved = existing.cloned().unwrap_or_default();
1312 resolved.schema_version = SCHEMA_VERSION;
1313 params.repo().clone_into(&mut resolved.project.repo);
1314 resolved.profile = Profile {
1315 technologies: Some(params.technologies().to_vec()),
1316 forge: Some(params.forge().unwrap_or_default().to_owned()),
1317 release: Release {
1318 mode: Some(params.release_mode()),
1319 driver: params.driver().map(str::to_owned),
1320 style: params.style(),
1321 line_prefix: params.profile().release.line_prefix.clone(),
1322 },
1323 };
1324 resolved.git = Git {
1325 trunk: Some(params.trunk().to_owned()),
1326 checkout_mode: Some(params.checkout_mode()),
1327 };
1328 resolved.capabilities = Capabilities {
1329 nix_packaging: Some(params.nix_packaging()),
1330 reporting_policy: Some(params.reporting_policy()),
1331 scorecard: Some(params.scorecard()),
1332 code_scanning: Some(
1333 params
1334 .code_scanning()
1335 .map_or("off", crate::landing::Provider::as_str)
1336 .to_owned(),
1337 ),
1338 };
1339 resolved.security.contact = Some(params.security_contact().to_owned());
1340 resolved.security.response = Some(params.security_response().to_owned());
1341 let content = if let Some(text) = text.filter(|_| existing.is_some()) {
1342 rewrite_all(text, parameter_values(&resolved))?
1343 } else {
1344 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
1345 };
1346 parse(&content)?;
1347 Ok(Self {
1348 action: if existing.is_some() {
1349 "updated"
1350 } else {
1351 "added"
1352 },
1353 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1354 content,
1355 })
1356 }
1357
1358 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1363 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
1364 Ok(())
1365 }
1366}
1367
1368fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1370 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1371 "project.repo",
1372 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1373 )];
1374 if let Some(list) = &config.profile.technologies {
1375 values.push(("profile.technologies", Some(array(list))));
1376 }
1377 if let Some(forge) = config.profile.forge.clone() {
1378 values.push(("profile.forge", Some(forge.into())));
1379 }
1380 if let Some(mode) = config.profile.release.mode {
1381 values.push(("profile.release.mode", Some(mode.as_str().into())));
1382 values.push((
1383 "profile.release.driver",
1384 config
1385 .profile
1386 .release
1387 .driver
1388 .clone()
1389 .map(toml_edit::Value::from),
1390 ));
1391 values.push((
1392 "profile.release.style",
1393 config
1394 .profile
1395 .release
1396 .style
1397 .map(|style| style.as_str().into()),
1398 ));
1399 values.push((
1400 "profile.release.line_prefix",
1401 config
1402 .profile
1403 .release
1404 .line_prefix
1405 .clone()
1406 .map(toml_edit::Value::from),
1407 ));
1408 }
1409 if let Some(trunk) = config.git.trunk.clone() {
1410 values.push(("git.trunk", Some(trunk.into())));
1411 }
1412 if let Some(mode) = config.git.checkout_mode {
1413 values.push(("git.checkout_mode", Some(mode.as_str().into())));
1414 }
1415 if let Some(value) = config.capabilities.nix_packaging {
1416 values.push(("capabilities.nix_packaging", Some(value.into())));
1417 }
1418 if let Some(value) = config.capabilities.reporting_policy {
1419 values.push(("capabilities.reporting_policy", Some(value.into())));
1420 }
1421 if let Some(value) = config.capabilities.scorecard {
1422 values.push(("capabilities.scorecard", Some(value.into())));
1423 }
1424 if let Some(value) = config.capabilities.code_scanning.clone() {
1425 values.push(("capabilities.code_scanning", Some(value.into())));
1426 }
1427 if let Some(value) = config.security.contact.clone() {
1430 values.push(("security.contact", Some(value.into())));
1431 }
1432 if let Some(value) = config.security.response.clone() {
1433 values.push(("security.response", Some(value.into())));
1434 }
1435 values
1436}
1437
1438#[must_use]
1440pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
1441 let recorded = {
1442 let params = crate::landing::Params::from_record(record);
1443 Plan::compose(None, ¶ms, None, None)
1444 .ok()
1445 .and_then(|plan| parse(&plan.content).ok())
1446 };
1447 let Some(recorded) = recorded else {
1448 return Vec::new();
1449 };
1450 let render = |value: &Option<toml_edit::Value>| {
1451 value.as_ref().map_or_else(
1452 || "<absent>".to_owned(),
1453 |value| value.to_string().trim().to_owned(),
1454 )
1455 };
1456 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
1457 .iter()
1458 .map(|(key, value)| (*key, render(value)))
1459 .collect();
1460 parameter_values(config)
1461 .into_iter()
1462 .map(|(key, value)| (key, render(&value)))
1463 .filter(|(key, value)| {
1464 baseline
1465 .iter()
1466 .any(|(other, old)| key == other && value != old)
1467 })
1468 .map(|(key, _)| key.to_owned())
1469 .collect()
1470}
1471
1472pub fn trunk_of(target: &Path) -> Result<String, RkError> {
1477 Ok(load(target)?
1478 .and_then(|config| config.git.trunk)
1479 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
1480}
1481
1482pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
1487 Ok(load(target)?
1488 .and_then(|config| config.profile.release.line_prefix)
1489 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
1495 use crate::landing::{CheckoutMode, Style};
1496 use crate::profile::ReleaseMode;
1497
1498 fn resolved_defaults() -> Config {
1501 Config {
1502 project: super::Project {
1503 repo: "acme/widget".into(),
1504 },
1505 profile: super::Profile {
1506 technologies: Some(vec!["rust".into()]),
1507 forge: Some("github".into()),
1508 release: super::Release {
1509 mode: Some(ReleaseMode::Automatic),
1510 driver: Some("rust".into()),
1511 style: Some(Style::Trunk),
1512 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
1513 },
1514 },
1515 git: super::Git {
1516 trunk: Some(super::TRUNK_DEFAULT.into()),
1517 checkout_mode: Some(CheckoutMode::LinkedWorktree),
1518 },
1519 capabilities: super::Capabilities {
1520 nix_packaging: Some(false),
1521 reporting_policy: Some(true),
1522 scorecard: Some(false),
1523 code_scanning: Some("off".to_owned()),
1524 },
1525 security: super::Security {
1528 contact: Some(String::new()),
1529 response: Some(super::RESPONSE_DEFAULT.into()),
1530 ..super::Security::default()
1531 },
1532 protection: super::Protection {
1535 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
1536 ..super::Protection::default()
1537 },
1538 ..Config::default()
1539 }
1540 }
1541
1542 #[test]
1543 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
1544 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
1545 let explicit = parse(
1546 "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",
1547 )
1548 .expect("explicit defaults parse");
1549 assert_eq!(omitted.git, super::Git::default());
1550 assert_eq!(omitted.capabilities, super::Capabilities::default());
1551 assert_eq!(
1552 explicit.git.checkout_mode,
1553 Some(CheckoutMode::LinkedWorktree)
1554 );
1555 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
1556 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
1557 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
1558 assert_eq!(explicit.capabilities.scorecard, Some(false));
1559 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
1560 assert_ne!(omitted, explicit);
1561 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
1563 .expect("the older spelling reads");
1564 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
1565 }
1566
1567 #[test]
1571 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
1572 let dir = tempfile::tempdir().expect("a tempdir");
1573 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
1574 std::fs::write(
1575 dir.path().join(CONFIG_PATH),
1576 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
1577 )
1578 .expect("the config writes");
1579 let held = load(dir.path())
1580 .expect("the config reads")
1581 .expect("it is present");
1582 assert_eq!(held.setup.bot.app_id, "123");
1583 assert_eq!(
1584 held.setup.bot.installation_id,
1585 Some(0),
1586 "the key parses; nothing reads it"
1587 );
1588 }
1589
1590 #[test]
1592 fn a_schema_1_config_migrates_into_its_domains() {
1593 let held = parse(
1594 "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",
1595 )
1596 .expect("a schema 1 file reads");
1597 assert_eq!(held.schema_version, 2);
1598 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
1599 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
1600 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
1601 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
1602 assert_eq!(held.profile.release.style, Some(Style::Lines));
1603 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
1604 assert_eq!(held.git.trunk.as_deref(), Some("main"));
1605 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
1606 assert_eq!(held.capabilities.nix_packaging, Some(false));
1607 assert_eq!(held.capabilities.reporting_policy, Some(true));
1608 }
1609
1610 #[test]
1612 fn every_invalid_release_state_names_its_key() {
1613 for (text, key) in [
1614 (
1615 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
1616 "profile.release.style",
1617 ),
1618 (
1619 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
1620 "profile.release.driver",
1621 ),
1622 (
1623 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
1624 "profile.release.line_prefix",
1625 ),
1626 (
1627 "[profile]\ntechnologies = ['rust', 'rust']\n",
1628 "profile.technologies",
1629 ),
1630 (
1631 "[profile]\ntechnologies = ['Rust!']\n",
1632 "profile.technologies",
1633 ),
1634 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
1635 (
1636 "[profile.release]\nmode = 'manual'\n",
1637 "profile.release.mode",
1638 ),
1639 ] {
1640 let error = parse(&format!("schema_version = 2\n{text}"))
1641 .expect_err("an invalid release state refuses")
1642 .to_string();
1643 assert!(error.contains(key), "{key}: {error}");
1644 }
1645 }
1646
1647 #[test]
1648 fn the_landed_config_template_round_trips() {
1649 let dir = tempfile::tempdir().expect("a target exists");
1650 let mut config = Config::default();
1651 config.project.repo = "acme/nested/widget".into();
1652 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
1653 config.profile.forge = Some("gitlab".into());
1654 config.profile.release = super::Release {
1655 mode: Some(ReleaseMode::Automatic),
1656 driver: Some("bash".into()),
1657 style: Some(Style::Lines),
1658 line_prefix: Some("stable/".into()),
1659 };
1660 config.git.trunk = Some("main".into());
1661 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
1662 config.capabilities.nix_packaging = Some(true);
1663 config.capabilities.reporting_policy = Some(false);
1664 config.capabilities.scorecard = Some(true);
1665 config.capabilities.code_scanning = Some("semgrep".to_owned());
1666 config.security.advisories =
1670 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
1671 config.security.contact = Some("security team, room 3 \"the vault\"".into());
1672 config.security.response = Some("14 business days".into());
1673 config.setup.required_check = "build / test".into();
1674 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
1675 config.setup.release_lines = true;
1676 config.setup.excluded_steps = [
1677 (
1678 "package-check".to_owned(),
1679 "nothing is published".to_owned(),
1680 ),
1681 (
1682 "protect-trunk".to_owned(),
1683 "this project merges \"locally\"".to_owned(),
1684 ),
1685 ]
1686 .into_iter()
1687 .collect();
1688 config.setup.bot.app_id = "123".into();
1689 config.protection.trunk_ruleset = Some("primary".into());
1690 config.protection.tag_ruleset = "versions".into();
1691 config.protection.lines_ruleset = "maintenance".into();
1692 config.protection.title_check = "intent".into();
1693 config.protection.tag_pattern = "refs/tags/*".into();
1694 config
1695 .protection
1696 .owned_trunk_rules
1697 .push("required_signatures".into());
1698 config.protection.required_approving_review_count = 2;
1699 config.protection.dismiss_stale_reviews_on_push = true;
1700 config.protection.require_code_owner_review = true;
1701 config.protection.require_last_push_approval = true;
1702 config.protection.gitlab.squash_commit_template =
1703 "%{title}\n\nContext: %{description}".into();
1704 config.protection.gitlab.merge_access_level = 40;
1705 let mut release_less = resolved_defaults();
1708 release_less.project.repo = String::new();
1709 release_less.profile.technologies = Some(Vec::new());
1710 release_less.profile.forge = Some(String::new());
1711 release_less.profile.release = super::Release {
1712 mode: Some(ReleaseMode::None),
1713 driver: None,
1714 style: None,
1715 line_prefix: None,
1716 };
1717 release_less.capabilities.reporting_policy = Some(false);
1718 for expected in [resolved_defaults(), config, release_less] {
1719 write(dir.path(), &expected).expect("the template renders");
1720 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
1721 let text =
1722 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1723 assert!(text.contains("# P: every technology"));
1724 assert!(text.contains("# F: invariant"));
1725 }
1726 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1727 assert!(
1728 !text.contains("style ="),
1729 "a none release writes no style: {text}"
1730 );
1731 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
1732 }
1733
1734 #[test]
1735 fn a_config_with_an_unknown_key_refuses_by_name() {
1736 for (table, typo, nearest) in [
1737 ("", "schemax_version", "schema_version"),
1738 ("git", "trunkx", "trunk"),
1739 ("profile.release", "stile", "style"),
1740 ("capabilities", "nix_packagingg", "nix_packaging"),
1741 ("security", "contactx", "contact"),
1742 ("setup", "required_checkx", "required_check"),
1743 ("setup.bot", "app_i", "app_id"),
1744 ("protection", "trunk_rulesett", "trunk_ruleset"),
1745 (
1746 "protection.github",
1747 "squash_body_sourcex",
1748 "squash_body_source",
1749 ),
1750 ("protection.gitlab", "squash_optionx", "squash_option"),
1751 ] {
1752 let header = if table.is_empty() {
1753 String::new()
1754 } else {
1755 format!("[{table}]\n")
1756 };
1757 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
1758 let error = parse(&text).expect_err("unknown keys refuse").to_string();
1759 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
1760 assert!(error.contains(expected), "{error}");
1761 }
1762 }
1763 }
1764
1765 #[test]
1769 fn an_exclusion_names_a_real_step_and_states_why() {
1770 for (text, expected) in [
1771 (
1772 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
1773 vec!["protect-trunkk", "nearest known step: protect-trunk"],
1774 ),
1775 (
1776 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
1777 vec!["protect-trunk", "no reason"],
1778 ),
1779 ] {
1780 let error = parse(&format!("schema_version = 2\n{text}"))
1781 .expect_err("the exclusion refuses")
1782 .to_string();
1783 for want in expected {
1784 assert!(error.contains(want), "{error}");
1785 }
1786 }
1787 let held = parse(
1788 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
1789 )
1790 .expect("a named step with a reason parses");
1791 assert_eq!(
1792 held.setup
1793 .excluded_steps
1794 .get("protect-trunk")
1795 .map(String::as_str),
1796 Some("we merge locally")
1797 );
1798 }
1799
1800 #[test]
1804 fn an_exclusion_does_not_lift_a_floor() {
1805 let error = parse(
1806 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
1807 )
1808 .expect_err("the floor binds an excluded step's keys too")
1809 .to_string();
1810 assert!(
1811 error.contains("protection.allowed_merge_methods"),
1812 "{error}"
1813 );
1814 }
1815
1816 #[test]
1817 fn a_config_at_an_unknown_schema_refuses() {
1818 for text in ["schema_version = 999", "schema_version = '2'", ""] {
1819 let error = parse(text)
1820 .expect_err("a schema must be declared and known")
1821 .to_string();
1822 assert!(
1823 error.contains(CONFIG_PATH) && error.contains("schema_version"),
1824 "{error}"
1825 );
1826 }
1827 }
1828
1829 #[test]
1830 fn an_unparsable_config_refuses_naming_the_position() {
1831 let error = parse("schema_version = 2\n[project\n")
1832 .expect_err("bad TOML refuses")
1833 .to_string();
1834 for expected in [CONFIG_PATH, "line 2", "column"] {
1835 assert!(error.contains(expected), "{error}");
1836 }
1837 }
1838
1839 #[test]
1840 fn an_absent_config_reads_as_none() {
1841 let dir = tempfile::tempdir().expect("a target exists");
1842 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
1843 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
1844 }
1845
1846 #[test]
1847 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
1848 let dir = tempfile::tempdir().expect("a target exists");
1849 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1850 std::fs::write(
1851 dir.path().join(CONFIG_PATH),
1852 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
1853 )
1854 .expect("a config exists");
1855 let error =
1856 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
1857 assert_eq!(error.exit_code(), 73);
1858 assert!(
1859 error
1860 .to_string()
1861 .contains("protection.strict_required_status_checks")
1862 );
1863 }
1864
1865 #[test]
1866 fn rewrite_key_preserves_comments() {
1867 let dir = tempfile::tempdir().expect("a target exists");
1868 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1869 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";
1870 let path = dir.path().join(CONFIG_PATH);
1871 std::fs::write(&path, original).expect("a config exists");
1872 rewrite_key(dir.path(), "profile.release.style", "lines".into())
1873 .expect("the style writes back");
1874 let text = std::fs::read_to_string(&path).expect("the text reads");
1875 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
1876 assert_eq!(
1877 load(dir.path())
1878 .expect("the config reads")
1879 .expect("present")
1880 .profile
1881 .release
1882 .style,
1883 Some(Style::Lines)
1884 );
1885 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
1886 .expect("an omitted table can be added");
1887 assert_eq!(
1888 load(dir.path())
1889 .expect("reads")
1890 .expect("present")
1891 .project
1892 .repo,
1893 "acme/widget"
1894 );
1895 rewrite_key(
1896 dir.path(),
1897 "security.contact",
1898 "security@acme.example".into(),
1899 )
1900 .expect("the contact is a landing parameter");
1901 rewrite_key(dir.path(), "security.response", "14 days".into())
1902 .expect("the response is a landing parameter");
1903 let held = load(dir.path()).expect("reads").expect("present");
1904 assert_eq!(
1905 held.security.contact.as_deref(),
1906 Some("security@acme.example")
1907 );
1908 assert_eq!(held.security.response.as_deref(), Some("14 days"));
1909 let text = std::fs::read_to_string(&path).expect("the text reads");
1910 assert!(text.contains("# keep me"), "the comment survives: {text}");
1911 let before = std::fs::read(&path).expect("the bytes read");
1912 for (key, value) in [
1913 ("security.advisories", "acme/private"),
1914 ("security.response", "90d"),
1915 ("profile.release.style", "unknown"),
1916 ] {
1917 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
1918 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
1919 }
1920 }
1921
1922 #[test]
1925 fn the_security_answers_are_held_to_their_grammar() {
1926 for value in ["team@acme.example", " https://acme.example/report ", ""] {
1927 super::canonical_contact(value).expect("a control-free line is a contact");
1928 }
1929 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
1930 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
1931 assert!(refusal.contains("security.contact"), "{refusal}");
1932 }
1933 assert_eq!(
1934 super::canonical_contact(" team@acme.example "),
1935 Ok("team@acme.example".to_owned()),
1936 "surrounding whitespace is trimmed"
1937 );
1938 for value in [
1939 "best-effort",
1940 "1 day",
1941 "2 days",
1942 "14 days",
1943 "1 business day",
1944 "14 business days",
1945 ] {
1946 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
1947 }
1948 assert_eq!(
1949 super::canonical_response(""),
1950 Ok(super::RESPONSE_DEFAULT.to_owned()),
1951 "an empty answer reads as the compiled default"
1952 );
1953 for value in [
1954 "0 days",
1955 "1 days",
1956 "2 day",
1957 "+2 days",
1958 "02 days",
1959 "4294967296 days",
1960 "90d",
1961 "two days",
1962 "we answer quickly",
1963 "2 weeks",
1964 ] {
1965 let refusal =
1966 super::canonical_response(value).expect_err("an unstateable window refuses");
1967 assert!(refusal.contains("security.response"), "{value}: {refusal}");
1968 assert!(refusal.contains("business days"), "{value}: {refusal}");
1969 }
1970 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
1971 .expect_err("the reader refuses it too")
1972 .to_string();
1973 assert!(refusal.contains("security.response"), "{refusal}");
1974 }
1975
1976 #[test]
1981 fn a_pruned_header_leaves_no_comment_behind() {
1982 let cases = [
1983 (
1984 "a middle table, comment above",
1985 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
1986 ),
1987 (
1988 "a middle table, comment inline",
1989 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
1990 ),
1991 (
1992 "the last table, comment above",
1993 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
1994 ),
1995 (
1996 "the last table, comment inline",
1997 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
1998 ),
1999 ];
2000 for (case, text) in cases {
2001 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2002 assert!(!next.contains("repo ="), "{case}: {next}");
2003 assert!(
2004 !next.contains("[project]"),
2005 "{case}: a table every one of whose keys dropped goes with them: {next}"
2006 );
2007 assert!(
2008 next.contains("# the operator's note"),
2009 "{case}: the comment the header carried survives: {next}"
2010 );
2011 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2012 }
2013 }
2014
2015 #[test]
2021 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2022 let text = concat!(
2023 "schema_version = 2\n\n[project]\n",
2024 "# the operator's note\n",
2025 "repo = \"acme/widget\" # P: project path on the forge\n\n",
2026 "[git]\ntrunk = \"main\"\n"
2027 );
2028 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2029 assert!(!next.contains("repo ="), "{next}");
2030 assert!(!next.contains("[project]"), "{next}");
2031 assert!(
2032 next.contains("# the operator's note"),
2033 "authored text survives: {next}"
2034 );
2035 assert!(
2036 !next.contains("# P:"),
2037 "the template's comment describes a key that is gone: {next}"
2038 );
2039 parse(&next).expect("the result parses");
2040
2041 let text = concat!(
2043 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2044 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2045 "# why this project names its own prefix\n",
2046 "line_prefix = \"stable/\"\n"
2047 );
2048 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2049 .expect("the key removes");
2050 assert!(!next.contains("line_prefix ="), "{next}");
2051 assert!(next.contains("[profile.release]"), "{next}");
2052 assert!(
2053 next.contains("# why this project names its own prefix"),
2054 "authored text survives: {next}"
2055 );
2056 }
2057}