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, PartialEq, Eq, Deserialize)]
87#[serde(deny_unknown_fields, default)]
88pub struct Security {
89 pub advisories: String,
91 pub contact: String,
93 pub response: String,
95}
96
97impl Default for Security {
98 fn default() -> Self {
99 Self {
100 advisories: String::new(),
101 contact: String::new(),
102 response: "best-effort".into(),
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
109#[serde(deny_unknown_fields, default)]
110pub struct Setup {
111 pub required_check: String,
113 pub retired_branches: Vec<String>,
115 pub line_prefix: Option<String>,
119 pub release_lines: bool,
121 pub excluded_steps: BTreeMap<String, String>,
126 pub bot: Bot,
128}
129
130impl Default for Setup {
131 fn default() -> Self {
132 Self {
133 required_check: String::new(),
134 retired_branches: vec!["main".into(), "develop".into()],
135 line_prefix: None,
136 release_lines: false,
137 excluded_steps: BTreeMap::new(),
138 bot: Bot::default(),
139 }
140 }
141}
142
143#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
150#[serde(deny_unknown_fields, default)]
151pub struct Bot {
152 pub app_id: String,
154 #[serde(default, skip_serializing)]
159 pub installation_id: Option<i64>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
164#[serde(deny_unknown_fields, default)]
165#[allow(
166 clippy::struct_excessive_bools,
167 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
168)]
169pub struct Protection {
170 pub trunk_ruleset: Option<String>,
174 pub tag_ruleset: String,
176 pub lines_ruleset: String,
178 pub title_check: String,
180 pub tag_pattern: String,
182 pub bypass_actors: Vec<String>,
184 pub allowed_merge_methods: Vec<String>,
186 pub strict_required_status_checks: bool,
188 pub owned_trunk_rules: Vec<String>,
190 pub required_approving_review_count: i64,
192 pub dismiss_stale_reviews_on_push: bool,
194 pub require_code_owner_review: bool,
196 pub require_last_push_approval: bool,
198 pub github: Github,
200 pub gitlab: Gitlab,
202}
203
204impl Default for Protection {
205 fn default() -> Self {
206 Self {
207 trunk_ruleset: None,
208 tag_ruleset: "release-tags".into(),
209 lines_ruleset: "release-lines".into(),
210 title_check: "pr-title".into(),
211 tag_pattern: "refs/tags/v*".into(),
212 bypass_actors: Vec::new(),
213 allowed_merge_methods: vec!["squash".into()],
214 strict_required_status_checks: true,
215 owned_trunk_rules: vec![
216 "deletion".into(),
217 "non_fast_forward".into(),
218 "pull_request".into(),
219 "required_status_checks".into(),
220 ],
221 required_approving_review_count: 0,
222 dismiss_stale_reviews_on_push: false,
223 require_code_owner_review: false,
224 require_last_push_approval: false,
225 github: Github::default(),
226 gitlab: Gitlab::default(),
227 }
228 }
229}
230
231impl Protection {
232 #[must_use]
235 pub fn trunk_ruleset(&self, trunk: &str) -> String {
236 self.trunk_ruleset
237 .clone()
238 .unwrap_or_else(|| format!("{trunk}-protection"))
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
244#[serde(deny_unknown_fields, default)]
245pub struct Github {
246 pub squash_title_source: String,
248 pub squash_body_source: String,
250}
251
252impl Default for Github {
253 fn default() -> Self {
254 Self {
255 squash_title_source: "PR_TITLE".into(),
256 squash_body_source: "PR_BODY".into(),
257 }
258 }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
263#[serde(deny_unknown_fields, default)]
264pub struct Gitlab {
265 pub merge_method: String,
267 pub squash_option: String,
269 pub squash_commit_template: String,
271 pub push_access_level: i64,
273 pub merge_access_level: i64,
275}
276
277impl Default for Gitlab {
278 fn default() -> Self {
279 Self {
280 merge_method: "ff".into(),
281 squash_option: "always".into(),
282 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
283 .trim_end_matches('\n')
284 .to_owned(),
285 push_access_level: 0,
286 merge_access_level: 40,
287 }
288 }
289}
290
291pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
296 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
297 Ok(text) => text,
298 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
299 Err(error) => return Err(error.into()),
300 };
301 parse(&text).map(Some)
302}
303
304fn parse(text: &str) -> Result<Config, RkError> {
305 let raw: toml::Value =
306 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
307 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
308 return Err(invalid(format!("schema_version must be {SCHEMA_VERSION}")));
309 }
310 let config: Config = toml::from_str(text).map_err(|error: toml::de::Error| {
311 let mut message = error.to_string();
312 if let Some(rest) = error.message().strip_prefix("unknown field `") {
313 let names: Vec<_> = rest.split('`').collect();
314 if let Some(unknown) = names.first() {
315 if let Some(nearest) = names
316 .iter()
317 .skip(2)
318 .step_by(2)
319 .min_by_key(|name| distance(unknown, name))
320 {
321 let _ = write!(message, "; nearest known key: {nearest}");
322 }
323 }
324 }
325 invalid(message)
326 })?;
327 if !config.project.forge.is_empty()
328 && crate::detect::Forge::parse(&config.project.forge).is_none()
329 {
330 return Err(invalid("project.forge must be github or gitlab"));
331 }
332 if !config.project.tech.is_empty()
333 && (config.project.tech.starts_with('_')
334 || crate::embedded::SNIPPETS
335 .get_dir(&config.project.tech)
336 .is_none())
337 {
338 return Err(invalid(
339 "project.tech must name a supported payload binding",
340 ));
341 }
342 exclusions(&config.setup.excluded_steps)?;
343 floors::check(&config)?;
344 Ok(config)
345}
346
347fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
354 for (name, reason) in excluded {
355 if crate::setup::steps::spec(name).is_none() {
356 let nearest = crate::setup::steps::STEPS
357 .iter()
358 .min_by_key(|step| distance(name, step.name))
359 .map_or("", |step| step.name);
360 return Err(invalid(format!(
361 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
362 )));
363 }
364 if reason.trim().is_empty() {
365 return Err(invalid(format!(
366 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
367 )));
368 }
369 }
370 Ok(())
371}
372
373pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
374 RkError::refusal(
375 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
376 .action(format!("edit {CONFIG_PATH} and retry"))
377 .target_state("nothing was written"),
378 )
379}
380
381fn distance(left: &str, right: &str) -> usize {
382 let mut row: Vec<_> = (0..=right.chars().count()).collect();
383 for (i, a) in left.chars().enumerate() {
384 let mut previous = row[0];
385 row[0] = i + 1;
386 for (j, b) in right.chars().enumerate() {
387 let old = row[j + 1];
388 row[j + 1] = (previous + usize::from(a != b))
389 .min(row[j] + 1)
390 .min(old + 1);
391 previous = old;
392 }
393 }
394 row.last().copied().unwrap_or(0)
395}
396
397pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
402 let bytes = render(config)?;
403 parse(&String::from_utf8_lossy(&bytes))?;
404 crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
405 Ok(())
406}
407
408fn array(values: &[String]) -> toml_edit::Value {
409 toml_edit::Value::Array(values.iter().collect())
410}
411
412fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
417 let mut table = toml_edit::InlineTable::new();
418 for (key, value) in values {
419 table.insert(key, value.clone().into());
420 }
421 toml_edit::Value::InlineTable(table)
422}
423
424#[allow(
425 clippy::too_many_lines,
426 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
427)]
428fn render(config: &Config) -> Result<Vec<u8>, RkError> {
429 let trunk = config
433 .project
434 .trunk
435 .clone()
436 .ok_or_else(|| invalid("project.trunk is unresolved"))?;
437 let mut fields: Vec<(&str, toml_edit::Value)> = vec![
438 ("RK_CONFIG_SCHEMA_VERSION", config.schema_version.into()),
439 ("RK_CONFIG_PROJECT_REPO", config.project.repo.clone().into()),
440 (
441 "RK_CONFIG_PROJECT_FORGE",
442 config.project.forge.clone().into(),
443 ),
444 ("RK_CONFIG_PROJECT_TECH", config.project.tech.clone().into()),
445 ("RK_CONFIG_PROJECT_TRUNK", trunk.clone().into()),
446 (
447 "RK_CONFIG_LANDING_WORKFLOW",
448 config
449 .landing
450 .workflow
451 .ok_or_else(|| invalid("landing.workflow is unresolved"))?
452 .as_str()
453 .into(),
454 ),
455 (
456 "RK_CONFIG_LANDING_STYLE",
457 config
458 .landing
459 .style
460 .ok_or_else(|| invalid("landing.style is unresolved"))?
461 .as_str()
462 .into(),
463 ),
464 (
465 "RK_CONFIG_LANDING_NIX",
466 config
467 .landing
468 .nix
469 .ok_or_else(|| invalid("landing.nix is unresolved"))?
470 .into(),
471 ),
472 (
473 "RK_CONFIG_SECURITY_ADVISORIES",
474 config.security.advisories.clone().into(),
475 ),
476 (
477 "RK_CONFIG_SECURITY_CONTACT",
478 config.security.contact.clone().into(),
479 ),
480 (
481 "RK_CONFIG_SECURITY_RESPONSE",
482 config.security.response.clone().into(),
483 ),
484 (
485 "RK_CONFIG_SETUP_REQUIRED_CHECK",
486 config.setup.required_check.clone().into(),
487 ),
488 (
489 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
490 array(&config.setup.retired_branches),
491 ),
492 (
493 "RK_CONFIG_SETUP_LINE_PREFIX",
494 config
495 .setup
496 .line_prefix
497 .clone()
498 .ok_or_else(|| invalid("setup.line_prefix is unresolved"))?
499 .into(),
500 ),
501 (
502 "RK_CONFIG_SETUP_RELEASE_LINES",
503 config.setup.release_lines.into(),
504 ),
505 (
506 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
507 inline(&config.setup.excluded_steps),
508 ),
509 (
510 "RK_CONFIG_SETUP_BOT_APP_ID",
511 config.setup.bot.app_id.clone().into(),
512 ),
513 ];
514 fields.extend(protection_fields(&config.protection, trunk.as_str()));
515 let template = crate::embedded::BLOCKS
516 .get_file("target-config.toml.in")
517 .and_then(include_dir::File::contents_utf8)
518 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
519 let mut bytes = Vec::new();
522 for line in template.split_inclusive('\n') {
523 if let Some((token, value)) = fields.iter().find(|(token, _)| line.contains(token)) {
524 bytes.extend(crate::landing::substitute(
525 line.as_bytes(),
526 token.as_bytes(),
527 value.to_string().as_bytes(),
528 ));
529 } else {
530 bytes.extend_from_slice(line.as_bytes());
531 }
532 }
533 Ok(bytes)
534}
535
536fn protection_fields(
537 protection: &Protection,
538 trunk: &str,
539) -> Vec<(&'static str, toml_edit::Value)> {
540 vec![
541 (
542 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
543 protection.trunk_ruleset(trunk).into(),
544 ),
545 (
546 "RK_CONFIG_PROTECTION_TAG_RULESET",
547 protection.tag_ruleset.clone().into(),
548 ),
549 (
550 "RK_CONFIG_PROTECTION_LINES_RULESET",
551 protection.lines_ruleset.clone().into(),
552 ),
553 (
554 "RK_CONFIG_PROTECTION_TITLE_CHECK",
555 protection.title_check.clone().into(),
556 ),
557 (
558 "RK_CONFIG_PROTECTION_TAG_PATTERN",
559 protection.tag_pattern.clone().into(),
560 ),
561 (
562 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
563 array(&protection.bypass_actors),
564 ),
565 (
566 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
567 array(&protection.allowed_merge_methods),
568 ),
569 (
570 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
571 protection.strict_required_status_checks.into(),
572 ),
573 (
574 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
575 array(&protection.owned_trunk_rules),
576 ),
577 (
578 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
579 protection.required_approving_review_count.into(),
580 ),
581 (
582 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
583 protection.dismiss_stale_reviews_on_push.into(),
584 ),
585 (
586 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
587 protection.require_code_owner_review.into(),
588 ),
589 (
590 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
591 protection.require_last_push_approval.into(),
592 ),
593 (
594 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
595 protection.github.squash_title_source.clone().into(),
596 ),
597 (
598 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
599 protection.github.squash_body_source.clone().into(),
600 ),
601 (
602 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
603 protection.gitlab.merge_method.clone().into(),
604 ),
605 (
606 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
607 protection.gitlab.squash_option.clone().into(),
608 ),
609 (
610 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
611 protection.gitlab.squash_commit_template.clone().into(),
612 ),
613 (
614 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
615 protection.gitlab.push_access_level.into(),
616 ),
617 (
618 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
619 protection.gitlab.merge_access_level.into(),
620 ),
621 ]
622}
623
624pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
629 let path = target.join(CONFIG_PATH);
630 let text = std::fs::read_to_string(&path)?;
631 let next = rewrite_text(&text, key, value)?;
632 crate::atomic::write(&path, next.as_bytes())?;
633 Ok(())
634}
635
636fn rewrite_text(text: &str, key: &str, mut value: toml_edit::Value) -> Result<String, RkError> {
637 if ![
638 "project.repo",
639 "project.forge",
640 "project.tech",
641 "project.trunk",
642 "landing.workflow",
643 "landing.style",
644 "landing.nix",
645 "setup.line_prefix",
646 ]
647 .contains(&key)
648 {
649 return Err(invalid(format!("{key} is not a landing parameter")));
650 }
651 parse(text)?;
652 let mut document = text
653 .parse::<toml_edit::DocumentMut>()
654 .map_err(|error| invalid(error.to_string()))?;
655 let mut item = document.as_item_mut();
656 for segment in key.split('.') {
657 item = &mut item[segment];
658 }
659 if let Some(old) = item.as_value() {
660 if old
661 .as_str()
662 .zip(value.as_str())
663 .is_some_and(|(old, new)| old == new)
664 || old
665 .as_bool()
666 .zip(value.as_bool())
667 .is_some_and(|(old, new)| old == new)
668 {
669 return Ok(text.to_owned());
670 }
671 *value.decor_mut() = old.decor().clone();
672 }
673 *item = toml_edit::Item::Value(value);
674 let next = document.to_string();
675 parse(&next)?;
676 Ok(next)
677}
678
679#[derive(Debug, serde::Serialize)]
681pub struct Plan {
682 pub action: &'static str,
684 pub changes: Vec<String>,
686 pub content: String,
688}
689
690impl Plan {
691 pub fn new(
696 target: &Path,
697 params: &crate::landing::Params,
698 existing: Option<&Config>,
699 record: Option<&crate::landing::manifest::Manifest>,
700 ) -> Result<Self, RkError> {
701 let mut resolved = existing.cloned().unwrap_or_default();
702 resolved.project.tech = params.tech().into();
703 resolved.project.forge = params.forge().into();
704 resolved.project.repo = params.repo().into();
705 resolved.landing = Landing {
706 workflow: Some(params.workflow()),
707 style: params.style(),
708 nix: Some(params.nix()),
709 };
710 resolved.project.trunk = Some(params.trunk().to_owned());
711 resolved.setup.line_prefix = Some(params.line_prefix().to_owned());
712 let content = if existing.is_some() {
713 let mut text = std::fs::read_to_string(target.join(CONFIG_PATH))?;
714 for (key, value) in parameter_values(&resolved) {
715 text = rewrite_text(&text, key, value)?;
716 }
717 text
718 } else {
719 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
720 };
721 parse(&content)?;
722 Ok(Self {
723 action: if existing.is_some() {
724 "updated"
725 } else {
726 "added"
727 },
728 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
729 content,
730 })
731 }
732
733 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
738 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
739 Ok(())
740 }
741}
742
743fn parameter_values(config: &Config) -> Vec<(&'static str, toml_edit::Value)> {
744 let mut values = Vec::new();
745 for (key, value) in [
746 ("project.repo", &config.project.repo),
747 ("project.forge", &config.project.forge),
748 ("project.tech", &config.project.tech),
749 ] {
750 if !value.is_empty() {
751 values.push((key, value.clone().into()));
752 }
753 }
754 if let Some(value) = config.landing.workflow {
755 values.push(("landing.workflow", value.as_str().into()));
756 }
757 if let Some(value) = config.landing.style {
758 values.push(("landing.style", value.as_str().into()));
759 }
760 if let Some(value) = config.landing.nix {
761 values.push(("landing.nix", value.into()));
762 }
763 if let Some(value) = config.project.trunk.clone() {
764 values.push(("project.trunk", value.into()));
765 }
766 if let Some(value) = config.setup.line_prefix.clone() {
767 values.push(("setup.line_prefix", value.into()));
768 }
769 values
770}
771
772#[must_use]
774pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
775 let mut recorded = Config::default();
776 recorded.project.repo.clone_from(&record.parameters.repo);
777 recorded.project.forge.clone_from(&record.forge);
778 recorded.project.tech.clone_from(&record.tech);
779 recorded.landing = Landing {
780 workflow: Some(record.parameters.workflow),
781 style: record.parameters.style,
782 nix: Some(record.parameters.nix),
783 };
784 recorded.project.trunk = Some(record.parameters.trunk.clone());
785 recorded.setup.line_prefix = Some(record.parameters.line_prefix.clone());
786 let baseline = parameter_values(&recorded);
787 parameter_values(config)
788 .into_iter()
789 .filter(|(key, value)| {
790 !baseline
791 .iter()
792 .any(|(other, old)| key == other && value.to_string() == old.to_string())
793 })
794 .map(|(key, _)| key.to_owned())
795 .collect()
796}
797
798pub fn trunk_of(target: &Path) -> Result<String, RkError> {
803 Ok(load(target)?
804 .and_then(|config| config.project.trunk)
805 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
806}
807
808pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
813 Ok(load(target)?
814 .and_then(|config| config.setup.line_prefix)
815 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
816}
817
818#[cfg(test)]
819mod tests {
820 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
821 use crate::landing::{Style, Workflow};
822
823 #[test]
824 fn an_omitted_landing_key_is_distinguishable_from_an_explicit_default() {
825 let omitted = parse("schema_version = 1\n").expect("omitted answers parse");
826 let explicit = parse(
827 "schema_version = 1\n[landing]\nworkflow = 'worktree'\nstyle = 'trunk'\nnix = false\n",
828 )
829 .expect("explicit defaults parse");
830 assert_eq!(omitted.landing, super::Landing::default());
831 assert_eq!(explicit.landing.workflow, Some(Workflow::Worktree));
832 assert_eq!(explicit.landing.style, Some(Style::Trunk));
833 assert_eq!(explicit.landing.nix, Some(false));
834 assert_ne!(omitted, explicit);
835 }
836
837 #[test]
841 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
842 let dir = tempfile::tempdir().expect("a tempdir");
843 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
844 std::fs::write(
845 dir.path().join(CONFIG_PATH),
846 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
847 )
848 .expect("the config writes");
849 let held = load(dir.path())
850 .expect("the config reads")
851 .expect("it is present");
852 assert_eq!(held.setup.bot.app_id, "123");
853 assert_eq!(
854 held.setup.bot.installation_id,
855 Some(0),
856 "the key parses; nothing reads it"
857 );
858 }
859
860 #[test]
861 fn the_landed_config_template_round_trips() {
862 let dir = tempfile::tempdir().expect("a target exists");
863 let mut config = Config::default();
864 config.project.repo = "acme/nested/widget".into();
865 config.project.forge = "gitlab".into();
866 config.project.tech = "bash".into();
867 config.project.trunk = Some("main".into());
868 config.landing.workflow = Some(Workflow::Branches);
869 config.landing.style = Some(Style::Lines);
870 config.landing.nix = Some(true);
871 config.security.advisories = "acme/private".into();
872 config.security.contact = "A \"quoted\" contact\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
873 config.security.response = "90d".into();
874 config.setup.required_check = "build / test".into();
875 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
876 config.setup.line_prefix = Some("stable/".into());
877 config.setup.release_lines = true;
878 config.setup.excluded_steps = [
879 (
880 "package-check".to_owned(),
881 "nothing is published".to_owned(),
882 ),
883 (
884 "protect-trunk".to_owned(),
885 "this project merges \"locally\"".to_owned(),
886 ),
887 ]
888 .into_iter()
889 .collect();
890 config.setup.bot.app_id = "123".into();
891 config.protection.trunk_ruleset = Some("primary".into());
892 config.protection.tag_ruleset = "versions".into();
893 config.protection.lines_ruleset = "maintenance".into();
894 config.protection.title_check = "intent".into();
895 config.protection.tag_pattern = "refs/tags/*".into();
896 config
897 .protection
898 .owned_trunk_rules
899 .push("required_signatures".into());
900 config.protection.required_approving_review_count = 2;
901 config.protection.dismiss_stale_reviews_on_push = true;
902 config.protection.require_code_owner_review = true;
903 config.protection.require_last_push_approval = true;
904 config.protection.gitlab.squash_commit_template =
905 "%{title}\n\nContext: %{description}".into();
906 config.protection.gitlab.merge_access_level = 40;
907 let defaults = Config {
908 landing: super::Landing {
909 workflow: Some(Workflow::Worktree),
910 style: Some(Style::Trunk),
911 nix: Some(false),
912 },
913 project: super::Project {
914 trunk: Some(super::TRUNK_DEFAULT.into()),
915 ..super::Project::default()
916 },
917 setup: super::Setup {
918 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
919 ..super::Setup::default()
920 },
921 protection: super::Protection {
924 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
925 ..super::Protection::default()
926 },
927 ..Config::default()
928 };
929 for expected in [defaults, config] {
930 write(dir.path(), &expected).expect("the template renders");
931 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
932 let text =
933 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
934 assert!(text.contains("# P: project path"));
935 assert!(text.contains("# F: invariant"));
936 }
937 }
938
939 #[test]
940 fn a_config_with_an_unknown_key_refuses_by_name() {
941 for (table, typo, nearest) in [
942 ("", "schemax_version", "schema_version"),
943 ("project", "trunkx", "trunk"),
944 ("landing", "stile", "style"),
945 ("security", "contactx", "contact"),
946 ("setup", "required_checkx", "required_check"),
947 ("setup.bot", "app_i", "app_id"),
948 ("protection", "trunk_rulesett", "trunk_ruleset"),
949 (
950 "protection.github",
951 "squash_body_sourcex",
952 "squash_body_source",
953 ),
954 ("protection.gitlab", "squash_optionx", "squash_option"),
955 ] {
956 let header = if table.is_empty() {
957 String::new()
958 } else {
959 format!("[{table}]\n")
960 };
961 let text = format!("schema_version = 1\n{header}{typo} = 'value'\n");
962 let error = parse(&text).expect_err("unknown keys refuse").to_string();
963 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
964 assert!(error.contains(expected), "{error}");
965 }
966 }
967 }
968
969 #[test]
973 fn an_exclusion_names_a_real_step_and_states_why() {
974 for (text, expected) in [
975 (
976 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
977 vec!["protect-trunkk", "nearest known step: protect-trunk"],
978 ),
979 (
980 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
981 vec!["protect-trunk", "no reason"],
982 ),
983 ] {
984 let error = parse(&format!("schema_version = 1\n{text}"))
985 .expect_err("the exclusion refuses")
986 .to_string();
987 for want in expected {
988 assert!(error.contains(want), "{error}");
989 }
990 }
991 let held = parse(
992 "schema_version = 1\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
993 )
994 .expect("a named step with a reason parses");
995 assert_eq!(
996 held.setup
997 .excluded_steps
998 .get("protect-trunk")
999 .map(String::as_str),
1000 Some("we merge locally")
1001 );
1002 }
1003
1004 #[test]
1008 fn an_exclusion_does_not_lift_a_floor() {
1009 let error = parse(
1010 "schema_version = 1\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
1011 )
1012 .expect_err("the floor binds an excluded step's keys too")
1013 .to_string();
1014 assert!(
1015 error.contains("protection.allowed_merge_methods"),
1016 "{error}"
1017 );
1018 }
1019
1020 #[test]
1021 fn a_config_at_an_unknown_schema_refuses() {
1022 for text in ["schema_version = 999", "schema_version = '1'", ""] {
1023 let error = parse(text)
1024 .expect_err("a schema must be declared and known")
1025 .to_string();
1026 assert!(
1027 error.contains(CONFIG_PATH) && error.contains("schema_version"),
1028 "{error}"
1029 );
1030 }
1031 }
1032
1033 #[test]
1034 fn an_unparsable_config_refuses_naming_the_position() {
1035 let error = parse("schema_version = 1\n[project\n")
1036 .expect_err("bad TOML refuses")
1037 .to_string();
1038 for expected in [CONFIG_PATH, "line 2", "column"] {
1039 assert!(error.contains(expected), "{error}");
1040 }
1041 }
1042
1043 #[test]
1044 fn an_absent_config_reads_as_none() {
1045 let dir = tempfile::tempdir().expect("a target exists");
1046 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
1047 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
1048 }
1049
1050 #[test]
1051 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
1052 let dir = tempfile::tempdir().expect("a target exists");
1053 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1054 std::fs::write(
1055 dir.path().join(CONFIG_PATH),
1056 "schema_version = 1\n[protection]\nstrict_required_status_checks = false\n",
1057 )
1058 .expect("a config exists");
1059 let error =
1060 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
1061 assert_eq!(error.exit_code(), 73);
1062 assert!(
1063 error
1064 .to_string()
1065 .contains("protection.strict_required_status_checks")
1066 );
1067 }
1068
1069 #[test]
1070 fn rewrite_key_preserves_comments() {
1071 let dir = tempfile::tempdir().expect("a target exists");
1072 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1073 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";
1074 let path = dir.path().join(CONFIG_PATH);
1075 std::fs::write(&path, original).expect("a config exists");
1076 rewrite_key(dir.path(), "landing.style", "lines".into()).expect("the style writes back");
1077 let text = std::fs::read_to_string(&path).expect("the text reads");
1078 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
1079 assert_eq!(
1080 load(dir.path())
1081 .expect("the config reads")
1082 .expect("present")
1083 .landing
1084 .style,
1085 Some(Style::Lines)
1086 );
1087 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
1088 .expect("an omitted table can be added");
1089 assert_eq!(
1090 load(dir.path())
1091 .expect("reads")
1092 .expect("present")
1093 .project
1094 .repo,
1095 "acme/widget"
1096 );
1097 let before = std::fs::read(&path).expect("the bytes read");
1098 for (key, value) in [("security.contact", "other"), ("landing.style", "unknown")] {
1099 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
1100 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
1101 }
1102 }
1103}