1pub mod floors;
5
6use std::fmt::Write as _;
7use std::path::Path;
8
9use crate::diagnostic::{Diagnostic, Reason};
10use crate::error::RkError;
11use crate::landing::{Style, Workflow};
12use serde::Deserialize;
13
14pub const CONFIG_PATH: &str = ".release-kit/config.toml";
16pub const SCHEMA_VERSION: i64 = 1;
18
19#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
21#[serde(deny_unknown_fields, default)]
22pub struct Config {
23 pub schema_version: i64,
25 pub project: Project,
27 pub landing: Landing,
29 pub security: Security,
31 pub setup: Setup,
33 pub protection: Protection,
35}
36
37impl Default for Config {
38 fn default() -> Self {
39 Self {
40 schema_version: SCHEMA_VERSION,
41 project: Project::default(),
42 landing: Landing::default(),
43 security: Security::default(),
44 setup: Setup::default(),
45 protection: Protection::default(),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
52#[serde(deny_unknown_fields, default)]
53pub struct Project {
54 pub repo: String,
56 pub forge: String,
58 pub tech: String,
60 pub trunk: Option<String>,
64}
65
66pub const TRUNK_DEFAULT: &str = "master";
68
69pub const LINE_PREFIX_DEFAULT: &str = "release/";
71
72#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
74#[serde(deny_unknown_fields, default)]
75pub struct Landing {
76 pub workflow: Option<Workflow>,
78 pub style: Option<Style>,
80 pub nix: Option<bool>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
86#[serde(deny_unknown_fields, default)]
87pub struct Security {
88 pub advisories: String,
90 pub contact: String,
92 pub response: String,
94}
95
96impl Default for Security {
97 fn default() -> Self {
98 Self {
99 advisories: String::new(),
100 contact: String::new(),
101 response: "best-effort".into(),
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
108#[serde(deny_unknown_fields, default)]
109pub struct Setup {
110 pub required_check: String,
112 pub retired_branches: Vec<String>,
114 pub line_prefix: Option<String>,
118 pub release_lines: bool,
120 pub bot: Bot,
122}
123
124impl Default for Setup {
125 fn default() -> Self {
126 Self {
127 required_check: String::new(),
128 retired_branches: vec!["main".into(), "develop".into()],
129 line_prefix: None,
130 release_lines: false,
131 bot: Bot::default(),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
143#[serde(deny_unknown_fields, default)]
144pub struct Bot {
145 pub app_id: String,
147 #[serde(default, skip_serializing)]
152 pub installation_id: Option<i64>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
157#[serde(deny_unknown_fields, default)]
158#[allow(clippy::struct_excessive_bools)]
160pub struct Protection {
161 pub trunk_ruleset: Option<String>,
165 pub tag_ruleset: String,
167 pub lines_ruleset: String,
169 pub title_check: String,
171 pub tag_pattern: String,
173 pub bypass_actors: Vec<String>,
175 pub allowed_merge_methods: Vec<String>,
177 pub strict_required_status_checks: bool,
179 pub owned_trunk_rules: Vec<String>,
181 pub required_approving_review_count: i64,
183 pub dismiss_stale_reviews_on_push: bool,
185 pub require_code_owner_review: bool,
187 pub require_last_push_approval: bool,
189 pub github: Github,
191 pub gitlab: Gitlab,
193}
194
195impl Default for Protection {
196 fn default() -> Self {
197 Self {
198 trunk_ruleset: None,
199 tag_ruleset: "release-tags".into(),
200 lines_ruleset: "release-lines".into(),
201 title_check: "pr-title".into(),
202 tag_pattern: "refs/tags/v*".into(),
203 bypass_actors: Vec::new(),
204 allowed_merge_methods: vec!["squash".into()],
205 strict_required_status_checks: true,
206 owned_trunk_rules: vec![
207 "deletion".into(),
208 "non_fast_forward".into(),
209 "pull_request".into(),
210 "required_status_checks".into(),
211 ],
212 required_approving_review_count: 0,
213 dismiss_stale_reviews_on_push: false,
214 require_code_owner_review: false,
215 require_last_push_approval: false,
216 github: Github::default(),
217 gitlab: Gitlab::default(),
218 }
219 }
220}
221
222impl Protection {
223 #[must_use]
226 pub fn trunk_ruleset(&self, trunk: &str) -> String {
227 self.trunk_ruleset
228 .clone()
229 .unwrap_or_else(|| format!("{trunk}-protection"))
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
235#[serde(deny_unknown_fields, default)]
236pub struct Github {
237 pub squash_title_source: String,
239 pub squash_body_source: String,
241}
242
243impl Default for Github {
244 fn default() -> Self {
245 Self {
246 squash_title_source: "PR_TITLE".into(),
247 squash_body_source: "PR_BODY".into(),
248 }
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
254#[serde(deny_unknown_fields, default)]
255pub struct Gitlab {
256 pub merge_method: String,
258 pub squash_option: String,
260 pub squash_commit_template: String,
262 pub push_access_level: i64,
264 pub merge_access_level: i64,
266}
267
268impl Default for Gitlab {
269 fn default() -> Self {
270 Self {
271 merge_method: "ff".into(),
272 squash_option: "always".into(),
273 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
274 .trim_end_matches('\n')
275 .to_owned(),
276 push_access_level: 0,
277 merge_access_level: 30,
278 }
279 }
280}
281
282pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
287 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
288 Ok(text) => text,
289 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
290 Err(error) => return Err(error.into()),
291 };
292 parse(&text).map(Some)
293}
294
295fn parse(text: &str) -> Result<Config, RkError> {
296 let raw: toml::Value =
297 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
298 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
299 return Err(invalid(format!("schema_version must be {SCHEMA_VERSION}")));
300 }
301 let config: Config = toml::from_str(text).map_err(|error: toml::de::Error| {
302 let mut message = error.to_string();
303 if let Some(rest) = error.message().strip_prefix("unknown field `") {
304 let names: Vec<_> = rest.split('`').collect();
305 if let Some(unknown) = names.first() {
306 if let Some(nearest) = names
307 .iter()
308 .skip(2)
309 .step_by(2)
310 .min_by_key(|name| distance(unknown, name))
311 {
312 let _ = write!(message, "; nearest known key: {nearest}");
313 }
314 }
315 }
316 invalid(message)
317 })?;
318 if !config.project.forge.is_empty()
319 && crate::detect::Forge::parse(&config.project.forge).is_none()
320 {
321 return Err(invalid("project.forge must be github or gitlab"));
322 }
323 if !config.project.tech.is_empty()
324 && (config.project.tech.starts_with('_')
325 || crate::embedded::SNIPPETS
326 .get_dir(&config.project.tech)
327 .is_none())
328 {
329 return Err(invalid(
330 "project.tech must name a supported payload binding",
331 ));
332 }
333 floors::check(&config)?;
334 Ok(config)
335}
336
337pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
338 RkError::refusal(
339 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
340 .action(format!("edit {CONFIG_PATH} and retry"))
341 .target_state("nothing was written"),
342 )
343}
344
345fn distance(left: &str, right: &str) -> usize {
346 let mut row: Vec<_> = (0..=right.chars().count()).collect();
347 for (i, a) in left.chars().enumerate() {
348 let mut previous = row[0];
349 row[0] = i + 1;
350 for (j, b) in right.chars().enumerate() {
351 let old = row[j + 1];
352 row[j + 1] = (previous + usize::from(a != b))
353 .min(row[j] + 1)
354 .min(old + 1);
355 previous = old;
356 }
357 }
358 row.last().copied().unwrap_or(0)
359}
360
361pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
366 let bytes = render(config)?;
367 parse(&String::from_utf8_lossy(&bytes))?;
368 crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
369 Ok(())
370}
371
372fn array(values: &[String]) -> toml_edit::Value {
373 toml_edit::Value::Array(values.iter().collect())
374}
375
376#[allow(clippy::too_many_lines)]
377fn render(config: &Config) -> Result<Vec<u8>, RkError> {
378 let trunk = config
382 .project
383 .trunk
384 .clone()
385 .ok_or_else(|| invalid("project.trunk is unresolved"))?;
386 let mut fields: Vec<(&str, toml_edit::Value)> = vec![
387 ("RK_CONFIG_SCHEMA_VERSION", config.schema_version.into()),
388 ("RK_CONFIG_PROJECT_REPO", config.project.repo.clone().into()),
389 (
390 "RK_CONFIG_PROJECT_FORGE",
391 config.project.forge.clone().into(),
392 ),
393 ("RK_CONFIG_PROJECT_TECH", config.project.tech.clone().into()),
394 ("RK_CONFIG_PROJECT_TRUNK", trunk.clone().into()),
395 (
396 "RK_CONFIG_LANDING_WORKFLOW",
397 config
398 .landing
399 .workflow
400 .ok_or_else(|| invalid("landing.workflow is unresolved"))?
401 .as_str()
402 .into(),
403 ),
404 (
405 "RK_CONFIG_LANDING_STYLE",
406 config
407 .landing
408 .style
409 .ok_or_else(|| invalid("landing.style is unresolved"))?
410 .as_str()
411 .into(),
412 ),
413 (
414 "RK_CONFIG_LANDING_NIX",
415 config
416 .landing
417 .nix
418 .ok_or_else(|| invalid("landing.nix is unresolved"))?
419 .into(),
420 ),
421 (
422 "RK_CONFIG_SECURITY_ADVISORIES",
423 config.security.advisories.clone().into(),
424 ),
425 (
426 "RK_CONFIG_SECURITY_CONTACT",
427 config.security.contact.clone().into(),
428 ),
429 (
430 "RK_CONFIG_SECURITY_RESPONSE",
431 config.security.response.clone().into(),
432 ),
433 (
434 "RK_CONFIG_SETUP_REQUIRED_CHECK",
435 config.setup.required_check.clone().into(),
436 ),
437 (
438 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
439 array(&config.setup.retired_branches),
440 ),
441 (
442 "RK_CONFIG_SETUP_LINE_PREFIX",
443 config
444 .setup
445 .line_prefix
446 .clone()
447 .ok_or_else(|| invalid("setup.line_prefix is unresolved"))?
448 .into(),
449 ),
450 (
451 "RK_CONFIG_SETUP_RELEASE_LINES",
452 config.setup.release_lines.into(),
453 ),
454 (
455 "RK_CONFIG_SETUP_BOT_APP_ID",
456 config.setup.bot.app_id.clone().into(),
457 ),
458 ];
459 fields.extend(protection_fields(&config.protection, trunk.as_str()));
460 let template = crate::embedded::BLOCKS
461 .get_file("target-config.toml.in")
462 .and_then(include_dir::File::contents_utf8)
463 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
464 let mut bytes = Vec::new();
467 for line in template.split_inclusive('\n') {
468 if let Some((token, value)) = fields.iter().find(|(token, _)| line.contains(token)) {
469 bytes.extend(crate::landing::substitute(
470 line.as_bytes(),
471 token.as_bytes(),
472 value.to_string().as_bytes(),
473 ));
474 } else {
475 bytes.extend_from_slice(line.as_bytes());
476 }
477 }
478 Ok(bytes)
479}
480
481fn protection_fields(
482 protection: &Protection,
483 trunk: &str,
484) -> Vec<(&'static str, toml_edit::Value)> {
485 vec![
486 (
487 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
488 protection.trunk_ruleset(trunk).into(),
489 ),
490 (
491 "RK_CONFIG_PROTECTION_TAG_RULESET",
492 protection.tag_ruleset.clone().into(),
493 ),
494 (
495 "RK_CONFIG_PROTECTION_LINES_RULESET",
496 protection.lines_ruleset.clone().into(),
497 ),
498 (
499 "RK_CONFIG_PROTECTION_TITLE_CHECK",
500 protection.title_check.clone().into(),
501 ),
502 (
503 "RK_CONFIG_PROTECTION_TAG_PATTERN",
504 protection.tag_pattern.clone().into(),
505 ),
506 (
507 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
508 array(&protection.bypass_actors),
509 ),
510 (
511 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
512 array(&protection.allowed_merge_methods),
513 ),
514 (
515 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
516 protection.strict_required_status_checks.into(),
517 ),
518 (
519 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
520 array(&protection.owned_trunk_rules),
521 ),
522 (
523 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
524 protection.required_approving_review_count.into(),
525 ),
526 (
527 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
528 protection.dismiss_stale_reviews_on_push.into(),
529 ),
530 (
531 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
532 protection.require_code_owner_review.into(),
533 ),
534 (
535 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
536 protection.require_last_push_approval.into(),
537 ),
538 (
539 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
540 protection.github.squash_title_source.clone().into(),
541 ),
542 (
543 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
544 protection.github.squash_body_source.clone().into(),
545 ),
546 (
547 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
548 protection.gitlab.merge_method.clone().into(),
549 ),
550 (
551 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
552 protection.gitlab.squash_option.clone().into(),
553 ),
554 (
555 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
556 protection.gitlab.squash_commit_template.clone().into(),
557 ),
558 (
559 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
560 protection.gitlab.push_access_level.into(),
561 ),
562 (
563 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
564 protection.gitlab.merge_access_level.into(),
565 ),
566 ]
567}
568
569pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
574 let path = target.join(CONFIG_PATH);
575 let text = std::fs::read_to_string(&path)?;
576 let next = rewrite_text(&text, key, value)?;
577 crate::atomic::write(&path, next.as_bytes())?;
578 Ok(())
579}
580
581fn rewrite_text(text: &str, key: &str, mut value: toml_edit::Value) -> Result<String, RkError> {
582 if ![
583 "project.repo",
584 "project.forge",
585 "project.tech",
586 "project.trunk",
587 "landing.workflow",
588 "landing.style",
589 "landing.nix",
590 "setup.line_prefix",
591 ]
592 .contains(&key)
593 {
594 return Err(invalid(format!("{key} is not a landing parameter")));
595 }
596 parse(text)?;
597 let mut document = text
598 .parse::<toml_edit::DocumentMut>()
599 .map_err(|error| invalid(error.to_string()))?;
600 let mut item = document.as_item_mut();
601 for segment in key.split('.') {
602 item = &mut item[segment];
603 }
604 if let Some(old) = item.as_value() {
605 if old
606 .as_str()
607 .zip(value.as_str())
608 .is_some_and(|(old, new)| old == new)
609 || old
610 .as_bool()
611 .zip(value.as_bool())
612 .is_some_and(|(old, new)| old == new)
613 {
614 return Ok(text.to_owned());
615 }
616 *value.decor_mut() = old.decor().clone();
617 }
618 *item = toml_edit::Item::Value(value);
619 let next = document.to_string();
620 parse(&next)?;
621 Ok(next)
622}
623
624#[derive(Debug, serde::Serialize)]
626pub struct Plan {
627 pub action: &'static str,
629 pub changes: Vec<String>,
631 pub content: String,
633}
634
635impl Plan {
636 pub fn new(
641 target: &Path,
642 params: &crate::landing::Params,
643 existing: Option<&Config>,
644 record: Option<&crate::landing::manifest::Manifest>,
645 ) -> Result<Self, RkError> {
646 let mut resolved = existing.cloned().unwrap_or_default();
647 resolved.project.tech = params.tech().into();
648 resolved.project.forge = params.forge().into();
649 resolved.project.repo = params.repo().into();
650 resolved.landing = Landing {
651 workflow: Some(params.workflow()),
652 style: params.style(),
653 nix: Some(params.nix()),
654 };
655 resolved.project.trunk = Some(params.trunk().to_owned());
656 resolved.setup.line_prefix = Some(params.line_prefix().to_owned());
657 let content = if existing.is_some() {
658 let mut text = std::fs::read_to_string(target.join(CONFIG_PATH))?;
659 for (key, value) in parameter_values(&resolved) {
660 text = rewrite_text(&text, key, value)?;
661 }
662 text
663 } else {
664 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
665 };
666 parse(&content)?;
667 Ok(Self {
668 action: if existing.is_some() {
669 "updated"
670 } else {
671 "added"
672 },
673 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
674 content,
675 })
676 }
677
678 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
683 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
684 Ok(())
685 }
686}
687
688fn parameter_values(config: &Config) -> Vec<(&'static str, toml_edit::Value)> {
689 let mut values = Vec::new();
690 for (key, value) in [
691 ("project.repo", &config.project.repo),
692 ("project.forge", &config.project.forge),
693 ("project.tech", &config.project.tech),
694 ] {
695 if !value.is_empty() {
696 values.push((key, value.clone().into()));
697 }
698 }
699 if let Some(value) = config.landing.workflow {
700 values.push(("landing.workflow", value.as_str().into()));
701 }
702 if let Some(value) = config.landing.style {
703 values.push(("landing.style", value.as_str().into()));
704 }
705 if let Some(value) = config.landing.nix {
706 values.push(("landing.nix", value.into()));
707 }
708 if let Some(value) = config.project.trunk.clone() {
709 values.push(("project.trunk", value.into()));
710 }
711 if let Some(value) = config.setup.line_prefix.clone() {
712 values.push(("setup.line_prefix", value.into()));
713 }
714 values
715}
716
717#[must_use]
719pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
720 let mut recorded = Config::default();
721 recorded.project.repo.clone_from(&record.parameters.repo);
722 recorded.project.forge.clone_from(&record.forge);
723 recorded.project.tech.clone_from(&record.tech);
724 recorded.landing = Landing {
725 workflow: Some(record.parameters.workflow),
726 style: record.parameters.style,
727 nix: Some(record.parameters.nix),
728 };
729 recorded.project.trunk = Some(record.parameters.trunk.clone());
730 recorded.setup.line_prefix = Some(record.parameters.line_prefix.clone());
731 let baseline = parameter_values(&recorded);
732 parameter_values(config)
733 .into_iter()
734 .filter(|(key, value)| {
735 !baseline
736 .iter()
737 .any(|(other, old)| key == other && value.to_string() == old.to_string())
738 })
739 .map(|(key, _)| key.to_owned())
740 .collect()
741}
742
743pub fn trunk_of(target: &Path) -> Result<String, RkError> {
748 Ok(load(target)?
749 .and_then(|config| config.project.trunk)
750 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
751}
752
753pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
758 Ok(load(target)?
759 .and_then(|config| config.setup.line_prefix)
760 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
761}
762
763#[cfg(test)]
764mod tests {
765 #![allow(clippy::expect_used)]
766
767 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
768 use crate::landing::{Style, Workflow};
769
770 #[test]
771 fn an_omitted_landing_key_is_distinguishable_from_an_explicit_default() {
772 let omitted = parse("schema_version = 1\n").expect("omitted answers parse");
773 let explicit = parse(
774 "schema_version = 1\n[landing]\nworkflow = 'worktree'\nstyle = 'trunk'\nnix = false\n",
775 )
776 .expect("explicit defaults parse");
777 assert_eq!(omitted.landing, super::Landing::default());
778 assert_eq!(explicit.landing.workflow, Some(Workflow::Worktree));
779 assert_eq!(explicit.landing.style, Some(Style::Trunk));
780 assert_eq!(explicit.landing.nix, Some(false));
781 assert_ne!(omitted, explicit);
782 }
783
784 #[test]
788 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
789 let dir = tempfile::tempdir().expect("a tempdir");
790 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
791 std::fs::write(
792 dir.path().join(CONFIG_PATH),
793 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
794 )
795 .expect("the config writes");
796 let held = load(dir.path())
797 .expect("the config reads")
798 .expect("it is present");
799 assert_eq!(held.setup.bot.app_id, "123");
800 assert_eq!(
801 held.setup.bot.installation_id,
802 Some(0),
803 "the key parses; nothing reads it"
804 );
805 }
806
807 #[test]
808 fn the_landed_config_template_round_trips() {
809 let dir = tempfile::tempdir().expect("a target exists");
810 let mut config = Config::default();
811 config.project.repo = "acme/nested/widget".into();
812 config.project.forge = "gitlab".into();
813 config.project.tech = "bash".into();
814 config.project.trunk = Some("main".into());
815 config.landing.workflow = Some(Workflow::Branches);
816 config.landing.style = Some(Style::Lines);
817 config.landing.nix = Some(true);
818 config.security.advisories = "acme/private".into();
819 config.security.contact = "A \"quoted\" contact\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
820 config.security.response = "90d".into();
821 config.setup.required_check = "build / test".into();
822 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
823 config.setup.line_prefix = Some("stable/".into());
824 config.setup.release_lines = true;
825 config.setup.bot.app_id = "123".into();
826 config.protection.trunk_ruleset = Some("primary".into());
827 config.protection.tag_ruleset = "versions".into();
828 config.protection.lines_ruleset = "maintenance".into();
829 config.protection.title_check = "intent".into();
830 config.protection.tag_pattern = "refs/tags/*".into();
831 config
832 .protection
833 .owned_trunk_rules
834 .push("required_signatures".into());
835 config.protection.required_approving_review_count = 2;
836 config.protection.dismiss_stale_reviews_on_push = true;
837 config.protection.require_code_owner_review = true;
838 config.protection.require_last_push_approval = true;
839 config.protection.gitlab.squash_commit_template =
840 "%{title}\n\nContext: %{description}".into();
841 config.protection.gitlab.merge_access_level = 40;
842 let defaults = Config {
843 landing: super::Landing {
844 workflow: Some(Workflow::Worktree),
845 style: Some(Style::Trunk),
846 nix: Some(false),
847 },
848 project: super::Project {
849 trunk: Some(super::TRUNK_DEFAULT.into()),
850 ..super::Project::default()
851 },
852 setup: super::Setup {
853 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
854 ..super::Setup::default()
855 },
856 protection: super::Protection {
859 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
860 ..super::Protection::default()
861 },
862 ..Config::default()
863 };
864 for expected in [defaults, config] {
865 write(dir.path(), &expected).expect("the template renders");
866 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
867 let text =
868 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
869 assert!(text.contains("# P: project path"));
870 assert!(text.contains("# F: invariant"));
871 }
872 }
873
874 #[test]
875 fn a_config_with_an_unknown_key_refuses_by_name() {
876 for (table, typo, nearest) in [
877 ("", "schemax_version", "schema_version"),
878 ("project", "trunkx", "trunk"),
879 ("landing", "stile", "style"),
880 ("security", "contactx", "contact"),
881 ("setup", "required_checkx", "required_check"),
882 ("setup.bot", "app_i", "app_id"),
883 ("protection", "trunk_rulesett", "trunk_ruleset"),
884 (
885 "protection.github",
886 "squash_body_sourcex",
887 "squash_body_source",
888 ),
889 ("protection.gitlab", "squash_optionx", "squash_option"),
890 ] {
891 let header = if table.is_empty() {
892 String::new()
893 } else {
894 format!("[{table}]\n")
895 };
896 let text = format!("schema_version = 1\n{header}{typo} = 'value'\n");
897 let error = parse(&text).expect_err("unknown keys refuse").to_string();
898 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
899 assert!(error.contains(expected), "{error}");
900 }
901 }
902 }
903
904 #[test]
905 fn a_config_at_an_unknown_schema_refuses() {
906 for text in ["schema_version = 999", "schema_version = '1'", ""] {
907 let error = parse(text)
908 .expect_err("a schema must be declared and known")
909 .to_string();
910 assert!(
911 error.contains(CONFIG_PATH) && error.contains("schema_version"),
912 "{error}"
913 );
914 }
915 }
916
917 #[test]
918 fn an_unparsable_config_refuses_naming_the_position() {
919 let error = parse("schema_version = 1\n[project\n")
920 .expect_err("bad TOML refuses")
921 .to_string();
922 for expected in [CONFIG_PATH, "line 2", "column"] {
923 assert!(error.contains(expected), "{error}");
924 }
925 }
926
927 #[test]
928 fn an_absent_config_reads_as_none() {
929 let dir = tempfile::tempdir().expect("a target exists");
930 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
931 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
932 }
933
934 #[test]
935 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
936 let dir = tempfile::tempdir().expect("a target exists");
937 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
938 std::fs::write(
939 dir.path().join(CONFIG_PATH),
940 "schema_version = 1\n[protection]\nstrict_required_status_checks = false\n",
941 )
942 .expect("a config exists");
943 let error =
944 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
945 assert_eq!(error.exit_code(), 73);
946 assert!(
947 error
948 .to_string()
949 .contains("protection.strict_required_status_checks")
950 );
951 }
952
953 #[test]
954 fn rewrite_key_preserves_comments() {
955 let dir = tempfile::tempdir().expect("a target exists");
956 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
957 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";
958 let path = dir.path().join(CONFIG_PATH);
959 std::fs::write(&path, original).expect("a config exists");
960 rewrite_key(dir.path(), "landing.style", "lines".into()).expect("the style writes back");
961 let text = std::fs::read_to_string(&path).expect("the text reads");
962 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
963 assert_eq!(
964 load(dir.path())
965 .expect("the config reads")
966 .expect("present")
967 .landing
968 .style,
969 Some(Style::Lines)
970 );
971 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
972 .expect("an omitted table can be added");
973 assert_eq!(
974 load(dir.path())
975 .expect("reads")
976 .expect("present")
977 .project
978 .repo,
979 "acme/widget"
980 );
981 let before = std::fs::read(&path).expect("the bytes read");
982 for (key, value) in [("security.contact", "other"), ("landing.style", "unknown")] {
983 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
984 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
985 }
986 }
987}