1pub mod floors;
5
6use std::collections::BTreeMap;
7use std::fmt::Write as _;
8use std::path::Path;
9
10use crate::diagnostic::{Diagnostic, Reason};
11use crate::error::RkError;
12use crate::landing::{Style, Workflow};
13use serde::Deserialize;
14
15pub const CONFIG_PATH: &str = ".release-kit/config.toml";
17pub const SCHEMA_VERSION: i64 = 1;
19
20#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
22#[serde(deny_unknown_fields, default)]
23pub struct Config {
24 pub schema_version: i64,
26 pub project: Project,
28 pub landing: Landing,
30 pub security: Security,
32 pub setup: Setup,
34 pub protection: Protection,
36}
37
38impl Default for Config {
39 fn default() -> Self {
40 Self {
41 schema_version: SCHEMA_VERSION,
42 project: Project::default(),
43 landing: Landing::default(),
44 security: Security::default(),
45 setup: Setup::default(),
46 protection: Protection::default(),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
53#[serde(deny_unknown_fields, default)]
54pub struct Project {
55 pub repo: String,
57 pub forge: String,
59 pub tech: String,
61 pub trunk: Option<String>,
65}
66
67pub const TRUNK_DEFAULT: &str = "master";
69
70pub const LINE_PREFIX_DEFAULT: &str = "release/";
72
73#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
75#[serde(deny_unknown_fields, default)]
76pub struct Landing {
77 pub workflow: Option<Workflow>,
79 pub style: Option<Style>,
81 pub nix: Option<bool>,
83 pub scorecard: Option<bool>,
85 pub code_scanning: Option<String>,
89}
90
91#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
93#[serde(deny_unknown_fields, default)]
94pub struct Security {
95 pub advisories: String,
97 pub contact: Option<String>,
102 pub response: Option<String>,
105}
106
107pub const RESPONSE_DEFAULT: &str = "best-effort";
110
111pub fn canonical_contact(raw: &str) -> Result<String, String> {
121 let trimmed = raw.trim();
122 if trimmed.chars().any(char::is_control) {
123 return Err(format!(
124 "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:?}"
125 ));
126 }
127 Ok(trimmed.to_owned())
128}
129
130pub fn canonical_response(raw: &str) -> Result<String, String> {
142 let trimmed = raw.trim();
143 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
144 return Ok(RESPONSE_DEFAULT.to_owned());
145 }
146 let refusal = || {
147 format!(
148 "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:?}"
149 )
150 };
151 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
152 let plural = match unit {
153 "day" | "business day" => false,
154 "days" | "business days" => true,
155 _ => return Err(refusal()),
156 };
157 let number: u32 = count.parse().map_err(|_| refusal())?;
158 if count != number.to_string() || (number > 1) != plural {
161 return Err(refusal());
162 }
163 Ok(trimmed.to_owned())
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
168#[serde(deny_unknown_fields, default)]
169pub struct Setup {
170 pub required_check: String,
172 pub retired_branches: Vec<String>,
174 pub line_prefix: Option<String>,
178 pub release_lines: bool,
180 pub excluded_steps: BTreeMap<String, String>,
185 pub bot: Bot,
187}
188
189impl Default for Setup {
190 fn default() -> Self {
191 Self {
192 required_check: String::new(),
193 retired_branches: vec!["main".into(), "develop".into()],
194 line_prefix: None,
195 release_lines: false,
196 excluded_steps: BTreeMap::new(),
197 bot: Bot::default(),
198 }
199 }
200}
201
202#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
209#[serde(deny_unknown_fields, default)]
210pub struct Bot {
211 pub app_id: String,
213 #[serde(default, skip_serializing)]
218 pub installation_id: Option<i64>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
223#[serde(deny_unknown_fields, default)]
224#[allow(
225 clippy::struct_excessive_bools,
226 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
227)]
228pub struct Protection {
229 pub trunk_ruleset: Option<String>,
233 pub tag_ruleset: String,
235 pub lines_ruleset: String,
237 pub title_check: String,
239 pub tag_pattern: String,
241 pub bypass_actors: Vec<String>,
243 pub allowed_merge_methods: Vec<String>,
245 pub strict_required_status_checks: bool,
247 pub owned_trunk_rules: Vec<String>,
249 pub required_approving_review_count: i64,
251 pub dismiss_stale_reviews_on_push: bool,
253 pub require_code_owner_review: bool,
255 pub require_last_push_approval: bool,
257 pub github: Github,
259 pub gitlab: Gitlab,
261}
262
263impl Default for Protection {
264 fn default() -> Self {
265 Self {
266 trunk_ruleset: None,
267 tag_ruleset: "release-tags".into(),
268 lines_ruleset: "release-lines".into(),
269 title_check: "pr-title".into(),
270 tag_pattern: "refs/tags/v*".into(),
271 bypass_actors: Vec::new(),
272 allowed_merge_methods: vec!["squash".into()],
273 strict_required_status_checks: true,
274 owned_trunk_rules: vec![
275 "deletion".into(),
276 "non_fast_forward".into(),
277 "pull_request".into(),
278 "required_status_checks".into(),
279 ],
280 required_approving_review_count: 0,
281 dismiss_stale_reviews_on_push: false,
282 require_code_owner_review: false,
283 require_last_push_approval: false,
284 github: Github::default(),
285 gitlab: Gitlab::default(),
286 }
287 }
288}
289
290impl Protection {
291 #[must_use]
294 pub fn trunk_ruleset(&self, trunk: &str) -> String {
295 self.trunk_ruleset
296 .clone()
297 .unwrap_or_else(|| format!("{trunk}-protection"))
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
303#[serde(deny_unknown_fields, default)]
304pub struct Github {
305 pub squash_title_source: String,
307 pub squash_body_source: String,
309}
310
311impl Default for Github {
312 fn default() -> Self {
313 Self {
314 squash_title_source: "PR_TITLE".into(),
315 squash_body_source: "PR_BODY".into(),
316 }
317 }
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
322#[serde(deny_unknown_fields, default)]
323pub struct Gitlab {
324 pub merge_method: String,
326 pub squash_option: String,
328 pub squash_commit_template: String,
330 pub push_access_level: i64,
332 pub merge_access_level: i64,
334}
335
336impl Default for Gitlab {
337 fn default() -> Self {
338 Self {
339 merge_method: "ff".into(),
340 squash_option: "always".into(),
341 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
342 .trim_end_matches('\n')
343 .to_owned(),
344 push_access_level: 0,
345 merge_access_level: 40,
346 }
347 }
348}
349
350pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
355 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
356 Ok(text) => text,
357 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
358 Err(error) => return Err(error.into()),
359 };
360 parse(&text).map(Some)
361}
362
363fn parse(text: &str) -> Result<Config, RkError> {
364 let raw: toml::Value =
365 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
366 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
367 return Err(invalid(format!("schema_version must be {SCHEMA_VERSION}")));
368 }
369 let config: Config = toml::from_str(text).map_err(|error: toml::de::Error| {
370 let mut message = error.to_string();
371 if let Some(rest) = error.message().strip_prefix("unknown field `") {
372 let names: Vec<_> = rest.split('`').collect();
373 if let Some(unknown) = names.first()
374 && let Some(nearest) = names
375 .iter()
376 .skip(2)
377 .step_by(2)
378 .min_by_key(|name| distance(unknown, name))
379 {
380 let _ = write!(message, "; nearest known key: {nearest}");
381 }
382 }
383 invalid(message)
384 })?;
385 if !config.project.forge.is_empty()
386 && crate::detect::Forge::parse(&config.project.forge).is_none()
387 {
388 return Err(invalid("project.forge must be github or gitlab"));
389 }
390 if !config.project.tech.is_empty()
391 && (config.project.tech.starts_with('_')
392 || crate::embedded::SNIPPETS
393 .get_dir(&config.project.tech)
394 .is_none())
395 {
396 return Err(invalid("project.tech must name a supported binding"));
397 }
398 if let Some(contact) = &config.security.contact {
399 canonical_contact(contact).map_err(invalid)?;
400 }
401 if let Some(response) = &config.security.response {
402 canonical_response(response).map_err(invalid)?;
403 }
404 exclusions(&config.setup.excluded_steps)?;
405 floors::check(&config)?;
406 Ok(config)
407}
408
409fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
416 for (name, reason) in excluded {
417 if crate::setup::steps::spec(name).is_none() {
418 let nearest = crate::setup::steps::STEPS
419 .iter()
420 .min_by_key(|step| distance(name, step.name))
421 .map_or("", |step| step.name);
422 return Err(invalid(format!(
423 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
424 )));
425 }
426 if reason.trim().is_empty() {
427 return Err(invalid(format!(
428 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
429 )));
430 }
431 }
432 Ok(())
433}
434
435pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
436 RkError::refusal(
437 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
438 .action(format!("edit {CONFIG_PATH} and retry"))
439 .target_state("nothing was written"),
440 )
441}
442
443fn distance(left: &str, right: &str) -> usize {
444 let mut row: Vec<_> = (0..=right.chars().count()).collect();
445 for (i, a) in left.chars().enumerate() {
446 let mut previous = row[0];
447 row[0] = i + 1;
448 for (j, b) in right.chars().enumerate() {
449 let old = row[j + 1];
450 row[j + 1] = (previous + usize::from(a != b))
451 .min(row[j] + 1)
452 .min(old + 1);
453 previous = old;
454 }
455 }
456 row.last().copied().unwrap_or(0)
457}
458
459pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
464 let bytes = render(config)?;
465 parse(&String::from_utf8_lossy(&bytes))?;
466 crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
467 Ok(())
468}
469
470fn array(values: &[String]) -> toml_edit::Value {
471 toml_edit::Value::Array(values.iter().collect())
472}
473
474fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
479 let mut table = toml_edit::InlineTable::new();
480 for (key, value) in values {
481 table.insert(key, value.clone().into());
482 }
483 toml_edit::Value::InlineTable(table)
484}
485
486#[allow(
487 clippy::too_many_lines,
488 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
489)]
490fn render(config: &Config) -> Result<Vec<u8>, RkError> {
491 let trunk = config
495 .project
496 .trunk
497 .clone()
498 .ok_or_else(|| invalid("project.trunk is unresolved"))?;
499 let mut fields: Vec<(&str, toml_edit::Value)> = vec![
500 ("RK_CONFIG_SCHEMA_VERSION", config.schema_version.into()),
501 ("RK_CONFIG_PROJECT_REPO", config.project.repo.clone().into()),
502 (
503 "RK_CONFIG_PROJECT_FORGE",
504 config.project.forge.clone().into(),
505 ),
506 ("RK_CONFIG_PROJECT_TECH", config.project.tech.clone().into()),
507 ("RK_CONFIG_PROJECT_TRUNK", trunk.clone().into()),
508 (
509 "RK_CONFIG_LANDING_WORKFLOW",
510 config
511 .landing
512 .workflow
513 .ok_or_else(|| invalid("landing.workflow is unresolved"))?
514 .as_str()
515 .into(),
516 ),
517 (
518 "RK_CONFIG_LANDING_STYLE",
519 config
520 .landing
521 .style
522 .ok_or_else(|| invalid("landing.style is unresolved"))?
523 .as_str()
524 .into(),
525 ),
526 (
527 "RK_CONFIG_LANDING_NIX",
528 config
529 .landing
530 .nix
531 .ok_or_else(|| invalid("landing.nix is unresolved"))?
532 .into(),
533 ),
534 (
535 "RK_CONFIG_LANDING_SCORECARD",
536 config
537 .landing
538 .scorecard
539 .ok_or_else(|| invalid("landing.scorecard is unresolved"))?
540 .into(),
541 ),
542 (
543 "RK_CONFIG_LANDING_CODE_SCANNING",
544 config
545 .landing
546 .code_scanning
547 .clone()
548 .ok_or_else(|| invalid("landing.code_scanning is unresolved"))?
549 .into(),
550 ),
551 (
552 "RK_CONFIG_SECURITY_ADVISORIES",
553 config.security.advisories.clone().into(),
554 ),
555 (
556 "RK_CONFIG_SECURITY_CONTACT",
557 config.security.contact.clone().unwrap_or_default().into(),
558 ),
559 (
560 "RK_CONFIG_SECURITY_RESPONSE",
561 config
562 .security
563 .response
564 .clone()
565 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
566 .into(),
567 ),
568 (
569 "RK_CONFIG_SETUP_REQUIRED_CHECK",
570 config.setup.required_check.clone().into(),
571 ),
572 (
573 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
574 array(&config.setup.retired_branches),
575 ),
576 (
577 "RK_CONFIG_SETUP_LINE_PREFIX",
578 config
579 .setup
580 .line_prefix
581 .clone()
582 .ok_or_else(|| invalid("setup.line_prefix is unresolved"))?
583 .into(),
584 ),
585 (
586 "RK_CONFIG_SETUP_RELEASE_LINES",
587 config.setup.release_lines.into(),
588 ),
589 (
590 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
591 inline(&config.setup.excluded_steps),
592 ),
593 (
594 "RK_CONFIG_SETUP_BOT_APP_ID",
595 config.setup.bot.app_id.clone().into(),
596 ),
597 ];
598 fields.extend(protection_fields(&config.protection, trunk.as_str()));
599 let template = crate::embedded::BLOCKS
600 .get_file("target-config.toml.in")
601 .and_then(include_dir::File::contents_utf8)
602 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
603 let mut bytes = Vec::new();
606 for line in template.split_inclusive('\n') {
607 if let Some((token, value)) = fields.iter().find(|(token, _)| line.contains(token)) {
608 bytes.extend(crate::landing::substitute(
609 line.as_bytes(),
610 token.as_bytes(),
611 value.to_string().as_bytes(),
612 ));
613 } else {
614 bytes.extend_from_slice(line.as_bytes());
615 }
616 }
617 Ok(bytes)
618}
619
620fn protection_fields(
621 protection: &Protection,
622 trunk: &str,
623) -> Vec<(&'static str, toml_edit::Value)> {
624 vec![
625 (
626 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
627 protection.trunk_ruleset(trunk).into(),
628 ),
629 (
630 "RK_CONFIG_PROTECTION_TAG_RULESET",
631 protection.tag_ruleset.clone().into(),
632 ),
633 (
634 "RK_CONFIG_PROTECTION_LINES_RULESET",
635 protection.lines_ruleset.clone().into(),
636 ),
637 (
638 "RK_CONFIG_PROTECTION_TITLE_CHECK",
639 protection.title_check.clone().into(),
640 ),
641 (
642 "RK_CONFIG_PROTECTION_TAG_PATTERN",
643 protection.tag_pattern.clone().into(),
644 ),
645 (
646 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
647 array(&protection.bypass_actors),
648 ),
649 (
650 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
651 array(&protection.allowed_merge_methods),
652 ),
653 (
654 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
655 protection.strict_required_status_checks.into(),
656 ),
657 (
658 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
659 array(&protection.owned_trunk_rules),
660 ),
661 (
662 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
663 protection.required_approving_review_count.into(),
664 ),
665 (
666 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
667 protection.dismiss_stale_reviews_on_push.into(),
668 ),
669 (
670 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
671 protection.require_code_owner_review.into(),
672 ),
673 (
674 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
675 protection.require_last_push_approval.into(),
676 ),
677 (
678 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
679 protection.github.squash_title_source.clone().into(),
680 ),
681 (
682 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
683 protection.github.squash_body_source.clone().into(),
684 ),
685 (
686 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
687 protection.gitlab.merge_method.clone().into(),
688 ),
689 (
690 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
691 protection.gitlab.squash_option.clone().into(),
692 ),
693 (
694 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
695 protection.gitlab.squash_commit_template.clone().into(),
696 ),
697 (
698 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
699 protection.gitlab.push_access_level.into(),
700 ),
701 (
702 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
703 protection.gitlab.merge_access_level.into(),
704 ),
705 ]
706}
707
708pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
713 let path = target.join(CONFIG_PATH);
714 let text = std::fs::read_to_string(&path)?;
715 let next = rewrite_text(&text, key, value)?;
716 crate::atomic::write(&path, next.as_bytes())?;
717 Ok(())
718}
719
720fn rewrite_text(text: &str, key: &str, mut value: toml_edit::Value) -> Result<String, RkError> {
721 if ![
722 "project.repo",
723 "project.forge",
724 "project.tech",
725 "project.trunk",
726 "landing.workflow",
727 "landing.style",
728 "landing.nix",
729 "landing.scorecard",
730 "landing.code_scanning",
731 "security.contact",
732 "security.response",
733 "setup.line_prefix",
734 ]
735 .contains(&key)
736 {
737 return Err(invalid(format!("{key} is not a landing parameter")));
738 }
739 parse(text)?;
740 let mut document = text
741 .parse::<toml_edit::DocumentMut>()
742 .map_err(|error| invalid(error.to_string()))?;
743 let mut item = document.as_item_mut();
744 for segment in key.split('.') {
745 item = &mut item[segment];
746 }
747 if let Some(old) = item.as_value() {
748 if old
749 .as_str()
750 .zip(value.as_str())
751 .is_some_and(|(old, new)| old == new)
752 || old
753 .as_bool()
754 .zip(value.as_bool())
755 .is_some_and(|(old, new)| old == new)
756 {
757 return Ok(text.to_owned());
758 }
759 *value.decor_mut() = old.decor().clone();
760 }
761 *item = toml_edit::Item::Value(value);
762 let next = document.to_string();
763 parse(&next)?;
764 Ok(next)
765}
766
767#[derive(Debug, Clone, serde::Serialize)]
769pub struct Plan {
770 pub action: &'static str,
772 pub changes: Vec<String>,
774 pub content: String,
776}
777
778impl Plan {
779 pub fn new(
784 target: &Path,
785 params: &crate::landing::Params,
786 existing: Option<&Config>,
787 record: Option<&crate::landing::manifest::Manifest>,
788 ) -> Result<Self, RkError> {
789 let text = existing
790 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
791 .transpose()?;
792 Self::compose(text.as_deref(), params, existing, record)
793 }
794
795 pub fn compose(
802 text: Option<&str>,
803 params: &crate::landing::Params,
804 existing: Option<&Config>,
805 record: Option<&crate::landing::manifest::Manifest>,
806 ) -> Result<Self, RkError> {
807 let mut resolved = existing.cloned().unwrap_or_default();
808 resolved.project.tech = params.tech().into();
809 resolved.project.forge = params.forge().into();
810 resolved.project.repo = params.repo().into();
811 resolved.landing = Landing {
812 workflow: Some(params.workflow()),
813 style: params.style(),
814 nix: Some(params.nix()),
815 scorecard: Some(params.scorecard()),
816 code_scanning: Some(
817 params
818 .code_scanning()
819 .map_or("off", crate::landing::Provider::as_str)
820 .to_owned(),
821 ),
822 };
823 resolved.project.trunk = Some(params.trunk().to_owned());
824 resolved.setup.line_prefix = Some(params.line_prefix().to_owned());
825 resolved.security.contact = Some(params.security_contact().to_owned());
826 resolved.security.response = Some(params.security_response().to_owned());
827 let content = if let Some(text) = text.filter(|_| existing.is_some()) {
828 let mut text = text.to_owned();
829 for (key, value) in parameter_values(&resolved) {
830 text = rewrite_text(&text, key, value)?;
831 }
832 text
833 } else {
834 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
835 };
836 parse(&content)?;
837 Ok(Self {
838 action: if existing.is_some() {
839 "updated"
840 } else {
841 "added"
842 },
843 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
844 content,
845 })
846 }
847
848 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
853 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
854 Ok(())
855 }
856}
857
858fn parameter_values(config: &Config) -> Vec<(&'static str, toml_edit::Value)> {
859 let mut values = Vec::new();
860 for (key, value) in [
861 ("project.repo", &config.project.repo),
862 ("project.forge", &config.project.forge),
863 ("project.tech", &config.project.tech),
864 ] {
865 if !value.is_empty() {
866 values.push((key, value.clone().into()));
867 }
868 }
869 if let Some(value) = config.landing.workflow {
870 values.push(("landing.workflow", value.as_str().into()));
871 }
872 if let Some(value) = config.landing.style {
873 values.push(("landing.style", value.as_str().into()));
874 }
875 if let Some(value) = config.landing.nix {
876 values.push(("landing.nix", value.into()));
877 }
878 if let Some(value) = config.landing.scorecard {
879 values.push(("landing.scorecard", value.into()));
880 }
881 if let Some(value) = config.landing.code_scanning.clone() {
882 values.push(("landing.code_scanning", value.into()));
883 }
884 if let Some(value) = config.project.trunk.clone() {
885 values.push(("project.trunk", value.into()));
886 }
887 if let Some(value) = config.setup.line_prefix.clone() {
888 values.push(("setup.line_prefix", value.into()));
889 }
890 if let Some(value) = config.security.contact.clone() {
893 values.push(("security.contact", value.into()));
894 }
895 if let Some(value) = config.security.response.clone() {
896 values.push(("security.response", value.into()));
897 }
898 values
899}
900
901#[must_use]
903pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
904 let mut recorded = Config::default();
905 recorded.project.repo.clone_from(&record.parameters.repo);
906 recorded.project.forge.clone_from(&record.forge);
907 recorded.project.tech.clone_from(&record.tech);
908 recorded.landing = Landing {
909 workflow: Some(record.parameters.workflow),
910 style: record.parameters.style,
911 nix: Some(record.parameters.nix),
912 scorecard: Some(record.parameters.scorecard),
913 code_scanning: Some(
914 record
915 .parameters
916 .code_scanning
917 .map_or("off", crate::landing::Provider::as_str)
918 .to_owned(),
919 ),
920 };
921 recorded.project.trunk = Some(record.parameters.trunk.clone());
922 recorded.setup.line_prefix = Some(record.parameters.line_prefix.clone());
923 recorded.security.contact = Some(record.parameters.security_contact.clone());
924 recorded.security.response = Some(record.parameters.security_response.clone());
925 let baseline = parameter_values(&recorded);
926 parameter_values(config)
927 .into_iter()
928 .filter(|(key, value)| {
929 !baseline
930 .iter()
931 .any(|(other, old)| key == other && value.to_string() == old.to_string())
932 })
933 .map(|(key, _)| key.to_owned())
934 .collect()
935}
936
937pub fn trunk_of(target: &Path) -> Result<String, RkError> {
942 Ok(load(target)?
943 .and_then(|config| config.project.trunk)
944 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
945}
946
947pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
952 Ok(load(target)?
953 .and_then(|config| config.setup.line_prefix)
954 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
955}
956
957#[cfg(test)]
958mod tests {
959 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
960 use crate::landing::{Style, Workflow};
961
962 #[test]
963 fn an_omitted_landing_key_is_distinguishable_from_an_explicit_default() {
964 let omitted = parse("schema_version = 1\n").expect("omitted answers parse");
965 let explicit = parse(
966 "schema_version = 1\n[landing]\nworkflow = 'worktree'\nstyle = 'trunk'\nnix = false\nscorecard = false\ncode_scanning = 'off'\n",
967 )
968 .expect("explicit defaults parse");
969 assert_eq!(omitted.landing, super::Landing::default());
970 assert_eq!(explicit.landing.workflow, Some(Workflow::Worktree));
971 assert_eq!(explicit.landing.style, Some(Style::Trunk));
972 assert_eq!(explicit.landing.nix, Some(false));
973 assert_eq!(explicit.landing.scorecard, Some(false));
974 assert_eq!(explicit.landing.code_scanning.as_deref(), Some("off"));
975 assert_ne!(omitted, explicit);
976 }
977
978 #[test]
982 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
983 let dir = tempfile::tempdir().expect("a tempdir");
984 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
985 std::fs::write(
986 dir.path().join(CONFIG_PATH),
987 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
988 )
989 .expect("the config writes");
990 let held = load(dir.path())
991 .expect("the config reads")
992 .expect("it is present");
993 assert_eq!(held.setup.bot.app_id, "123");
994 assert_eq!(
995 held.setup.bot.installation_id,
996 Some(0),
997 "the key parses; nothing reads it"
998 );
999 }
1000
1001 #[test]
1002 fn the_landed_config_template_round_trips() {
1003 let dir = tempfile::tempdir().expect("a target exists");
1004 let mut config = Config::default();
1005 config.project.repo = "acme/nested/widget".into();
1006 config.project.forge = "gitlab".into();
1007 config.project.tech = "bash".into();
1008 config.project.trunk = Some("main".into());
1009 config.landing.workflow = Some(Workflow::Branches);
1010 config.landing.style = Some(Style::Lines);
1011 config.landing.nix = Some(true);
1012 config.landing.scorecard = Some(true);
1013 config.landing.code_scanning = Some("semgrep".to_owned());
1014 config.security.advisories =
1018 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
1019 config.security.contact = Some("security team, room 3 \"the vault\"".into());
1020 config.security.response = Some("14 business days".into());
1021 config.setup.required_check = "build / test".into();
1022 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
1023 config.setup.line_prefix = Some("stable/".into());
1024 config.setup.release_lines = true;
1025 config.setup.excluded_steps = [
1026 (
1027 "package-check".to_owned(),
1028 "nothing is published".to_owned(),
1029 ),
1030 (
1031 "protect-trunk".to_owned(),
1032 "this project merges \"locally\"".to_owned(),
1033 ),
1034 ]
1035 .into_iter()
1036 .collect();
1037 config.setup.bot.app_id = "123".into();
1038 config.protection.trunk_ruleset = Some("primary".into());
1039 config.protection.tag_ruleset = "versions".into();
1040 config.protection.lines_ruleset = "maintenance".into();
1041 config.protection.title_check = "intent".into();
1042 config.protection.tag_pattern = "refs/tags/*".into();
1043 config
1044 .protection
1045 .owned_trunk_rules
1046 .push("required_signatures".into());
1047 config.protection.required_approving_review_count = 2;
1048 config.protection.dismiss_stale_reviews_on_push = true;
1049 config.protection.require_code_owner_review = true;
1050 config.protection.require_last_push_approval = true;
1051 config.protection.gitlab.squash_commit_template =
1052 "%{title}\n\nContext: %{description}".into();
1053 config.protection.gitlab.merge_access_level = 40;
1054 let defaults = Config {
1055 landing: super::Landing {
1056 workflow: Some(Workflow::Worktree),
1057 style: Some(Style::Trunk),
1058 nix: Some(false),
1059 scorecard: Some(false),
1060 code_scanning: Some("off".to_owned()),
1061 },
1062 project: super::Project {
1063 trunk: Some(super::TRUNK_DEFAULT.into()),
1064 ..super::Project::default()
1065 },
1066 setup: super::Setup {
1067 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
1068 ..super::Setup::default()
1069 },
1070 security: super::Security {
1073 contact: Some(String::new()),
1074 response: Some(super::RESPONSE_DEFAULT.into()),
1075 ..super::Security::default()
1076 },
1077 protection: super::Protection {
1080 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
1081 ..super::Protection::default()
1082 },
1083 ..Config::default()
1084 };
1085 for expected in [defaults, config] {
1086 write(dir.path(), &expected).expect("the template renders");
1087 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
1088 let text =
1089 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1090 assert!(text.contains("# P: project path"));
1091 assert!(text.contains("# F: invariant"));
1092 }
1093 }
1094
1095 #[test]
1096 fn a_config_with_an_unknown_key_refuses_by_name() {
1097 for (table, typo, nearest) in [
1098 ("", "schemax_version", "schema_version"),
1099 ("project", "trunkx", "trunk"),
1100 ("landing", "stile", "style"),
1101 ("security", "contactx", "contact"),
1102 ("setup", "required_checkx", "required_check"),
1103 ("setup.bot", "app_i", "app_id"),
1104 ("protection", "trunk_rulesett", "trunk_ruleset"),
1105 (
1106 "protection.github",
1107 "squash_body_sourcex",
1108 "squash_body_source",
1109 ),
1110 ("protection.gitlab", "squash_optionx", "squash_option"),
1111 ] {
1112 let header = if table.is_empty() {
1113 String::new()
1114 } else {
1115 format!("[{table}]\n")
1116 };
1117 let text = format!("schema_version = 1\n{header}{typo} = 'value'\n");
1118 let error = parse(&text).expect_err("unknown keys refuse").to_string();
1119 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
1120 assert!(error.contains(expected), "{error}");
1121 }
1122 }
1123 }
1124
1125 #[test]
1129 fn an_exclusion_names_a_real_step_and_states_why() {
1130 for (text, expected) in [
1131 (
1132 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
1133 vec!["protect-trunkk", "nearest known step: protect-trunk"],
1134 ),
1135 (
1136 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
1137 vec!["protect-trunk", "no reason"],
1138 ),
1139 ] {
1140 let error = parse(&format!("schema_version = 1\n{text}"))
1141 .expect_err("the exclusion refuses")
1142 .to_string();
1143 for want in expected {
1144 assert!(error.contains(want), "{error}");
1145 }
1146 }
1147 let held = parse(
1148 "schema_version = 1\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
1149 )
1150 .expect("a named step with a reason parses");
1151 assert_eq!(
1152 held.setup
1153 .excluded_steps
1154 .get("protect-trunk")
1155 .map(String::as_str),
1156 Some("we merge locally")
1157 );
1158 }
1159
1160 #[test]
1164 fn an_exclusion_does_not_lift_a_floor() {
1165 let error = parse(
1166 "schema_version = 1\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
1167 )
1168 .expect_err("the floor binds an excluded step's keys too")
1169 .to_string();
1170 assert!(
1171 error.contains("protection.allowed_merge_methods"),
1172 "{error}"
1173 );
1174 }
1175
1176 #[test]
1177 fn a_config_at_an_unknown_schema_refuses() {
1178 for text in ["schema_version = 999", "schema_version = '1'", ""] {
1179 let error = parse(text)
1180 .expect_err("a schema must be declared and known")
1181 .to_string();
1182 assert!(
1183 error.contains(CONFIG_PATH) && error.contains("schema_version"),
1184 "{error}"
1185 );
1186 }
1187 }
1188
1189 #[test]
1190 fn an_unparsable_config_refuses_naming_the_position() {
1191 let error = parse("schema_version = 1\n[project\n")
1192 .expect_err("bad TOML refuses")
1193 .to_string();
1194 for expected in [CONFIG_PATH, "line 2", "column"] {
1195 assert!(error.contains(expected), "{error}");
1196 }
1197 }
1198
1199 #[test]
1200 fn an_absent_config_reads_as_none() {
1201 let dir = tempfile::tempdir().expect("a target exists");
1202 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
1203 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
1204 }
1205
1206 #[test]
1207 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
1208 let dir = tempfile::tempdir().expect("a target exists");
1209 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1210 std::fs::write(
1211 dir.path().join(CONFIG_PATH),
1212 "schema_version = 1\n[protection]\nstrict_required_status_checks = false\n",
1213 )
1214 .expect("a config exists");
1215 let error =
1216 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
1217 assert_eq!(error.exit_code(), 73);
1218 assert!(
1219 error
1220 .to_string()
1221 .contains("protection.strict_required_status_checks")
1222 );
1223 }
1224
1225 #[test]
1226 fn rewrite_key_preserves_comments() {
1227 let dir = tempfile::tempdir().expect("a target exists");
1228 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1229 let original = "# Project answers\nschema_version = 1\n\n[security] # first table stays first\ncontact = 'team' # keep me\n\n[landing]\n# Our release choice\nstyle = 'trunk' # keep this reason\nworkflow = 'branches'\n";
1230 let path = dir.path().join(CONFIG_PATH);
1231 std::fs::write(&path, original).expect("a config exists");
1232 rewrite_key(dir.path(), "landing.style", "lines".into()).expect("the style writes back");
1233 let text = std::fs::read_to_string(&path).expect("the text reads");
1234 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
1235 assert_eq!(
1236 load(dir.path())
1237 .expect("the config reads")
1238 .expect("present")
1239 .landing
1240 .style,
1241 Some(Style::Lines)
1242 );
1243 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
1244 .expect("an omitted table can be added");
1245 assert_eq!(
1246 load(dir.path())
1247 .expect("reads")
1248 .expect("present")
1249 .project
1250 .repo,
1251 "acme/widget"
1252 );
1253 rewrite_key(
1254 dir.path(),
1255 "security.contact",
1256 "security@acme.example".into(),
1257 )
1258 .expect("the contact is a landing parameter now");
1259 rewrite_key(dir.path(), "security.response", "14 days".into())
1260 .expect("the response is a landing parameter now");
1261 let held = load(dir.path()).expect("reads").expect("present");
1262 assert_eq!(
1263 held.security.contact.as_deref(),
1264 Some("security@acme.example")
1265 );
1266 assert_eq!(held.security.response.as_deref(), Some("14 days"));
1267 let text = std::fs::read_to_string(&path).expect("the text reads");
1268 assert!(text.contains("# keep me"), "the comment survives: {text}");
1269 let before = std::fs::read(&path).expect("the bytes read");
1270 for (key, value) in [
1271 ("security.advisories", "acme/private"),
1272 ("security.response", "90d"),
1273 ("landing.style", "unknown"),
1274 ] {
1275 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
1276 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
1277 }
1278 }
1279
1280 #[test]
1283 fn the_security_answers_are_held_to_their_grammar() {
1284 for value in ["team@acme.example", " https://acme.example/report ", ""] {
1285 super::canonical_contact(value).expect("a control-free line is a contact");
1286 }
1287 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
1288 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
1289 assert!(refusal.contains("security.contact"), "{refusal}");
1290 }
1291 assert_eq!(
1292 super::canonical_contact(" team@acme.example "),
1293 Ok("team@acme.example".to_owned()),
1294 "surrounding whitespace is trimmed"
1295 );
1296 for value in [
1297 "best-effort",
1298 "1 day",
1299 "2 days",
1300 "14 days",
1301 "1 business day",
1302 "14 business days",
1303 ] {
1304 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
1305 }
1306 assert_eq!(
1307 super::canonical_response(""),
1308 Ok(super::RESPONSE_DEFAULT.to_owned()),
1309 "an empty answer reads as the compiled default"
1310 );
1311 for value in [
1312 "0 days",
1313 "1 days",
1314 "2 day",
1315 "+2 days",
1316 "02 days",
1317 "4294967296 days",
1318 "90d",
1319 "two days",
1320 "we answer quickly",
1321 "2 weeks",
1322 ] {
1323 let refusal =
1324 super::canonical_response(value).expect_err("an unstateable window refuses");
1325 assert!(refusal.contains("security.response"), "{value}: {refusal}");
1326 assert!(refusal.contains("business days"), "{value}: {refusal}");
1327 }
1328 let refusal = parse("schema_version = 1\n[security]\nresponse = '90d'\n")
1329 .expect_err("the reader refuses it too")
1330 .to_string();
1331 assert!(refusal.contains("security.response"), "{refusal}");
1332 }
1333}