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