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)]
138#[serde(deny_unknown_fields, default)]
139pub struct Bot {
140 pub app_id: String,
142 pub installation_id: i64,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
148#[serde(deny_unknown_fields, default)]
149#[allow(clippy::struct_excessive_bools)]
151pub struct Protection {
152 pub trunk_ruleset: String,
154 pub tag_ruleset: String,
156 pub lines_ruleset: String,
158 pub title_check: String,
160 pub tag_pattern: String,
162 pub bypass_actors: Vec<String>,
164 pub allowed_merge_methods: Vec<String>,
166 pub strict_required_status_checks: bool,
168 pub owned_trunk_rules: Vec<String>,
170 pub required_approving_review_count: i64,
172 pub dismiss_stale_reviews_on_push: bool,
174 pub require_code_owner_review: bool,
176 pub require_last_push_approval: bool,
178 pub github: Github,
180 pub gitlab: Gitlab,
182}
183
184impl Default for Protection {
185 fn default() -> Self {
186 Self {
187 trunk_ruleset: "master-protection".into(),
188 tag_ruleset: "release-tags".into(),
189 lines_ruleset: "release-lines".into(),
190 title_check: "pr-title".into(),
191 tag_pattern: "refs/tags/v*".into(),
192 bypass_actors: Vec::new(),
193 allowed_merge_methods: vec!["squash".into()],
194 strict_required_status_checks: true,
195 owned_trunk_rules: vec![
196 "deletion".into(),
197 "non_fast_forward".into(),
198 "pull_request".into(),
199 "required_status_checks".into(),
200 ],
201 required_approving_review_count: 0,
202 dismiss_stale_reviews_on_push: false,
203 require_code_owner_review: false,
204 require_last_push_approval: false,
205 github: Github::default(),
206 gitlab: Gitlab::default(),
207 }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
213#[serde(deny_unknown_fields, default)]
214pub struct Github {
215 pub squash_title_source: String,
217 pub squash_body_source: String,
219}
220
221impl Default for Github {
222 fn default() -> Self {
223 Self {
224 squash_title_source: "PR_TITLE".into(),
225 squash_body_source: "PR_BODY".into(),
226 }
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
232#[serde(deny_unknown_fields, default)]
233pub struct Gitlab {
234 pub merge_method: String,
236 pub squash_option: String,
238 pub squash_commit_template: String,
240 pub push_access_level: i64,
242 pub merge_access_level: i64,
244}
245
246impl Default for Gitlab {
247 fn default() -> Self {
248 Self {
249 merge_method: "ff".into(),
250 squash_option: "always".into(),
251 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
252 .trim_end_matches('\n')
253 .to_owned(),
254 push_access_level: 0,
255 merge_access_level: 30,
256 }
257 }
258}
259
260pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
265 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
266 Ok(text) => text,
267 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
268 Err(error) => return Err(error.into()),
269 };
270 parse(&text).map(Some)
271}
272
273fn parse(text: &str) -> Result<Config, RkError> {
274 let raw: toml::Value =
275 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
276 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
277 return Err(invalid(format!("schema_version must be {SCHEMA_VERSION}")));
278 }
279 let config: Config = toml::from_str(text).map_err(|error: toml::de::Error| {
280 let mut message = error.to_string();
281 if let Some(rest) = error.message().strip_prefix("unknown field `") {
282 let names: Vec<_> = rest.split('`').collect();
283 if let Some(unknown) = names.first() {
284 if let Some(nearest) = names
285 .iter()
286 .skip(2)
287 .step_by(2)
288 .min_by_key(|name| distance(unknown, name))
289 {
290 let _ = write!(message, "; nearest known key: {nearest}");
291 }
292 }
293 }
294 invalid(message)
295 })?;
296 if !config.project.forge.is_empty()
297 && crate::detect::Forge::parse(&config.project.forge).is_none()
298 {
299 return Err(invalid("project.forge must be github or gitlab"));
300 }
301 if !config.project.tech.is_empty()
302 && (config.project.tech.starts_with('_')
303 || crate::embedded::SNIPPETS
304 .get_dir(&config.project.tech)
305 .is_none())
306 {
307 return Err(invalid(
308 "project.tech must name a supported payload binding",
309 ));
310 }
311 floors::check(&config)?;
312 Ok(config)
313}
314
315pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
316 RkError::refusal(
317 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
318 .action(format!("edit {CONFIG_PATH} and retry"))
319 .target_state("nothing was written"),
320 )
321}
322
323fn distance(left: &str, right: &str) -> usize {
324 let mut row: Vec<_> = (0..=right.chars().count()).collect();
325 for (i, a) in left.chars().enumerate() {
326 let mut previous = row[0];
327 row[0] = i + 1;
328 for (j, b) in right.chars().enumerate() {
329 let old = row[j + 1];
330 row[j + 1] = (previous + usize::from(a != b))
331 .min(row[j] + 1)
332 .min(old + 1);
333 previous = old;
334 }
335 }
336 row.last().copied().unwrap_or(0)
337}
338
339pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
344 let bytes = render(config)?;
345 parse(&String::from_utf8_lossy(&bytes))?;
346 crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
347 Ok(())
348}
349
350fn array(values: &[String]) -> toml_edit::Value {
351 toml_edit::Value::Array(values.iter().collect())
352}
353
354#[allow(clippy::too_many_lines)]
355fn render(config: &Config) -> Result<Vec<u8>, RkError> {
356 let mut fields: Vec<(&str, toml_edit::Value)> = vec![
357 ("RK_CONFIG_SCHEMA_VERSION", config.schema_version.into()),
358 ("RK_CONFIG_PROJECT_REPO", config.project.repo.clone().into()),
359 (
360 "RK_CONFIG_PROJECT_FORGE",
361 config.project.forge.clone().into(),
362 ),
363 ("RK_CONFIG_PROJECT_TECH", config.project.tech.clone().into()),
364 (
365 "RK_CONFIG_PROJECT_TRUNK",
366 config
367 .project
368 .trunk
369 .clone()
370 .ok_or_else(|| invalid("project.trunk is unresolved"))?
371 .into(),
372 ),
373 (
374 "RK_CONFIG_LANDING_WORKFLOW",
375 config
376 .landing
377 .workflow
378 .ok_or_else(|| invalid("landing.workflow is unresolved"))?
379 .as_str()
380 .into(),
381 ),
382 (
383 "RK_CONFIG_LANDING_STYLE",
384 config
385 .landing
386 .style
387 .ok_or_else(|| invalid("landing.style is unresolved"))?
388 .as_str()
389 .into(),
390 ),
391 (
392 "RK_CONFIG_LANDING_NIX",
393 config
394 .landing
395 .nix
396 .ok_or_else(|| invalid("landing.nix is unresolved"))?
397 .into(),
398 ),
399 (
400 "RK_CONFIG_SECURITY_ADVISORIES",
401 config.security.advisories.clone().into(),
402 ),
403 (
404 "RK_CONFIG_SECURITY_CONTACT",
405 config.security.contact.clone().into(),
406 ),
407 (
408 "RK_CONFIG_SECURITY_RESPONSE",
409 config.security.response.clone().into(),
410 ),
411 (
412 "RK_CONFIG_SETUP_REQUIRED_CHECK",
413 config.setup.required_check.clone().into(),
414 ),
415 (
416 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
417 array(&config.setup.retired_branches),
418 ),
419 (
420 "RK_CONFIG_SETUP_LINE_PREFIX",
421 config
422 .setup
423 .line_prefix
424 .clone()
425 .ok_or_else(|| invalid("setup.line_prefix is unresolved"))?
426 .into(),
427 ),
428 (
429 "RK_CONFIG_SETUP_RELEASE_LINES",
430 config.setup.release_lines.into(),
431 ),
432 (
433 "RK_CONFIG_SETUP_BOT_APP_ID",
434 config.setup.bot.app_id.clone().into(),
435 ),
436 (
437 "RK_CONFIG_SETUP_BOT_INSTALLATION_ID",
438 config.setup.bot.installation_id.into(),
439 ),
440 ];
441 fields.extend(protection_fields(&config.protection));
442 let template = crate::embedded::BLOCKS
443 .get_file("target-config.toml.in")
444 .and_then(include_dir::File::contents_utf8)
445 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
446 let mut bytes = Vec::new();
449 for line in template.split_inclusive('\n') {
450 if let Some((token, value)) = fields.iter().find(|(token, _)| line.contains(token)) {
451 bytes.extend(crate::landing::substitute(
452 line.as_bytes(),
453 token.as_bytes(),
454 value.to_string().as_bytes(),
455 ));
456 } else {
457 bytes.extend_from_slice(line.as_bytes());
458 }
459 }
460 Ok(bytes)
461}
462
463fn protection_fields(protection: &Protection) -> Vec<(&'static str, toml_edit::Value)> {
464 vec![
465 (
466 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
467 protection.trunk_ruleset.clone().into(),
468 ),
469 (
470 "RK_CONFIG_PROTECTION_TAG_RULESET",
471 protection.tag_ruleset.clone().into(),
472 ),
473 (
474 "RK_CONFIG_PROTECTION_LINES_RULESET",
475 protection.lines_ruleset.clone().into(),
476 ),
477 (
478 "RK_CONFIG_PROTECTION_TITLE_CHECK",
479 protection.title_check.clone().into(),
480 ),
481 (
482 "RK_CONFIG_PROTECTION_TAG_PATTERN",
483 protection.tag_pattern.clone().into(),
484 ),
485 (
486 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
487 array(&protection.bypass_actors),
488 ),
489 (
490 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
491 array(&protection.allowed_merge_methods),
492 ),
493 (
494 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
495 protection.strict_required_status_checks.into(),
496 ),
497 (
498 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
499 array(&protection.owned_trunk_rules),
500 ),
501 (
502 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
503 protection.required_approving_review_count.into(),
504 ),
505 (
506 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
507 protection.dismiss_stale_reviews_on_push.into(),
508 ),
509 (
510 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
511 protection.require_code_owner_review.into(),
512 ),
513 (
514 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
515 protection.require_last_push_approval.into(),
516 ),
517 (
518 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
519 protection.github.squash_title_source.clone().into(),
520 ),
521 (
522 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
523 protection.github.squash_body_source.clone().into(),
524 ),
525 (
526 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
527 protection.gitlab.merge_method.clone().into(),
528 ),
529 (
530 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
531 protection.gitlab.squash_option.clone().into(),
532 ),
533 (
534 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
535 protection.gitlab.squash_commit_template.clone().into(),
536 ),
537 (
538 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
539 protection.gitlab.push_access_level.into(),
540 ),
541 (
542 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
543 protection.gitlab.merge_access_level.into(),
544 ),
545 ]
546}
547
548pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
553 let path = target.join(CONFIG_PATH);
554 let text = std::fs::read_to_string(&path)?;
555 let next = rewrite_text(&text, key, value)?;
556 crate::atomic::write(&path, next.as_bytes())?;
557 Ok(())
558}
559
560fn rewrite_text(text: &str, key: &str, mut value: toml_edit::Value) -> Result<String, RkError> {
561 if ![
562 "project.repo",
563 "project.forge",
564 "project.tech",
565 "project.trunk",
566 "landing.workflow",
567 "landing.style",
568 "landing.nix",
569 "setup.line_prefix",
570 ]
571 .contains(&key)
572 {
573 return Err(invalid(format!("{key} is not a landing parameter")));
574 }
575 parse(text)?;
576 let mut document = text
577 .parse::<toml_edit::DocumentMut>()
578 .map_err(|error| invalid(error.to_string()))?;
579 let mut item = document.as_item_mut();
580 for segment in key.split('.') {
581 item = &mut item[segment];
582 }
583 if let Some(old) = item.as_value() {
584 if old
585 .as_str()
586 .zip(value.as_str())
587 .is_some_and(|(old, new)| old == new)
588 || old
589 .as_bool()
590 .zip(value.as_bool())
591 .is_some_and(|(old, new)| old == new)
592 {
593 return Ok(text.to_owned());
594 }
595 *value.decor_mut() = old.decor().clone();
596 }
597 *item = toml_edit::Item::Value(value);
598 let next = document.to_string();
599 parse(&next)?;
600 Ok(next)
601}
602
603#[derive(Debug, serde::Serialize)]
605pub struct Plan {
606 pub action: &'static str,
608 pub changes: Vec<String>,
610 pub content: String,
612}
613
614impl Plan {
615 pub fn new(
620 target: &Path,
621 params: &crate::landing::Params,
622 existing: Option<&Config>,
623 record: Option<&crate::landing::manifest::Manifest>,
624 ) -> Result<Self, RkError> {
625 let mut resolved = existing.cloned().unwrap_or_default();
626 resolved.project.tech = params.tech().into();
627 resolved.project.forge = params.forge().into();
628 resolved.project.repo = params.repo().into();
629 resolved.landing = Landing {
630 workflow: Some(params.workflow()),
631 style: params.style(),
632 nix: Some(params.nix()),
633 };
634 resolved.project.trunk = Some(params.trunk().to_owned());
635 resolved.setup.line_prefix = Some(params.line_prefix().to_owned());
636 let content = if existing.is_some() {
637 let mut text = std::fs::read_to_string(target.join(CONFIG_PATH))?;
638 for (key, value) in parameter_values(&resolved) {
639 text = rewrite_text(&text, key, value)?;
640 }
641 text
642 } else {
643 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
644 };
645 parse(&content)?;
646 Ok(Self {
647 action: if existing.is_some() {
648 "updated"
649 } else {
650 "added"
651 },
652 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
653 content,
654 })
655 }
656
657 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
662 crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
663 Ok(())
664 }
665}
666
667fn parameter_values(config: &Config) -> Vec<(&'static str, toml_edit::Value)> {
668 let mut values = Vec::new();
669 for (key, value) in [
670 ("project.repo", &config.project.repo),
671 ("project.forge", &config.project.forge),
672 ("project.tech", &config.project.tech),
673 ] {
674 if !value.is_empty() {
675 values.push((key, value.clone().into()));
676 }
677 }
678 if let Some(value) = config.landing.workflow {
679 values.push(("landing.workflow", value.as_str().into()));
680 }
681 if let Some(value) = config.landing.style {
682 values.push(("landing.style", value.as_str().into()));
683 }
684 if let Some(value) = config.landing.nix {
685 values.push(("landing.nix", value.into()));
686 }
687 if let Some(value) = config.project.trunk.clone() {
688 values.push(("project.trunk", value.into()));
689 }
690 if let Some(value) = config.setup.line_prefix.clone() {
691 values.push(("setup.line_prefix", value.into()));
692 }
693 values
694}
695
696#[must_use]
698pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
699 let mut recorded = Config::default();
700 recorded.project.repo.clone_from(&record.parameters.repo);
701 recorded.project.forge.clone_from(&record.forge);
702 recorded.project.tech.clone_from(&record.tech);
703 recorded.landing = Landing {
704 workflow: Some(record.parameters.workflow),
705 style: record.parameters.style,
706 nix: Some(record.parameters.nix),
707 };
708 recorded.project.trunk = Some(record.parameters.trunk.clone());
709 recorded.setup.line_prefix = Some(record.parameters.line_prefix.clone());
710 let baseline = parameter_values(&recorded);
711 parameter_values(config)
712 .into_iter()
713 .filter(|(key, value)| {
714 !baseline
715 .iter()
716 .any(|(other, old)| key == other && value.to_string() == old.to_string())
717 })
718 .map(|(key, _)| key.to_owned())
719 .collect()
720}
721
722pub fn trunk_of(target: &Path) -> Result<String, RkError> {
727 Ok(load(target)?
728 .and_then(|config| config.project.trunk)
729 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
730}
731
732pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
737 Ok(load(target)?
738 .and_then(|config| config.setup.line_prefix)
739 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
740}
741
742#[cfg(test)]
743mod tests {
744 #![allow(clippy::expect_used)]
745
746 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
747 use crate::landing::{Style, Workflow};
748
749 #[test]
750 fn an_omitted_landing_key_is_distinguishable_from_an_explicit_default() {
751 let omitted = parse("schema_version = 1\n").expect("omitted answers parse");
752 let explicit = parse(
753 "schema_version = 1\n[landing]\nworkflow = 'worktree'\nstyle = 'trunk'\nnix = false\n",
754 )
755 .expect("explicit defaults parse");
756 assert_eq!(omitted.landing, super::Landing::default());
757 assert_eq!(explicit.landing.workflow, Some(Workflow::Worktree));
758 assert_eq!(explicit.landing.style, Some(Style::Trunk));
759 assert_eq!(explicit.landing.nix, Some(false));
760 assert_ne!(omitted, explicit);
761 }
762
763 #[test]
764 fn the_landed_config_template_round_trips() {
765 let dir = tempfile::tempdir().expect("a target exists");
766 let mut config = Config::default();
767 config.project.repo = "acme/nested/widget".into();
768 config.project.forge = "gitlab".into();
769 config.project.tech = "bash".into();
770 config.project.trunk = Some("main".into());
771 config.landing.workflow = Some(Workflow::Branches);
772 config.landing.style = Some(Style::Lines);
773 config.landing.nix = Some(true);
774 config.security.advisories = "acme/private".into();
775 config.security.contact = "A \"quoted\" contact\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
776 config.security.response = "90d".into();
777 config.setup.required_check = "build / test".into();
778 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
779 config.setup.line_prefix = Some("stable/".into());
780 config.setup.release_lines = true;
781 config.setup.bot.app_id = "123".into();
782 config.setup.bot.installation_id = 456;
783 config.protection.trunk_ruleset = "primary".into();
784 config.protection.tag_ruleset = "versions".into();
785 config.protection.lines_ruleset = "maintenance".into();
786 config.protection.title_check = "intent".into();
787 config.protection.tag_pattern = "refs/tags/*".into();
788 config
789 .protection
790 .owned_trunk_rules
791 .push("required_signatures".into());
792 config.protection.required_approving_review_count = 2;
793 config.protection.dismiss_stale_reviews_on_push = true;
794 config.protection.require_code_owner_review = true;
795 config.protection.require_last_push_approval = true;
796 config.protection.gitlab.squash_commit_template =
797 "%{title}\n\nContext: %{description}".into();
798 config.protection.gitlab.merge_access_level = 40;
799 let defaults = Config {
800 landing: super::Landing {
801 workflow: Some(Workflow::Worktree),
802 style: Some(Style::Trunk),
803 nix: Some(false),
804 },
805 project: super::Project {
806 trunk: Some(super::TRUNK_DEFAULT.into()),
807 ..super::Project::default()
808 },
809 setup: super::Setup {
810 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
811 ..super::Setup::default()
812 },
813 ..Config::default()
814 };
815 for expected in [defaults, config] {
816 write(dir.path(), &expected).expect("the template renders");
817 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
818 let text =
819 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
820 assert!(text.contains("# P: project path"));
821 assert!(text.contains("# F: invariant"));
822 }
823 }
824
825 #[test]
826 fn a_config_with_an_unknown_key_refuses_by_name() {
827 for (table, typo, nearest) in [
828 ("", "schemax_version", "schema_version"),
829 ("project", "trunkx", "trunk"),
830 ("landing", "stile", "style"),
831 ("security", "contactx", "contact"),
832 ("setup", "required_checkx", "required_check"),
833 ("setup.bot", "app_i", "app_id"),
834 ("protection", "trunk_rulesett", "trunk_ruleset"),
835 (
836 "protection.github",
837 "squash_body_sourcex",
838 "squash_body_source",
839 ),
840 ("protection.gitlab", "squash_optionx", "squash_option"),
841 ] {
842 let header = if table.is_empty() {
843 String::new()
844 } else {
845 format!("[{table}]\n")
846 };
847 let text = format!("schema_version = 1\n{header}{typo} = 'value'\n");
848 let error = parse(&text).expect_err("unknown keys refuse").to_string();
849 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
850 assert!(error.contains(expected), "{error}");
851 }
852 }
853 }
854
855 #[test]
856 fn a_config_at_an_unknown_schema_refuses() {
857 for text in ["schema_version = 999", "schema_version = '1'", ""] {
858 let error = parse(text)
859 .expect_err("a schema must be declared and known")
860 .to_string();
861 assert!(
862 error.contains(CONFIG_PATH) && error.contains("schema_version"),
863 "{error}"
864 );
865 }
866 }
867
868 #[test]
869 fn an_unparsable_config_refuses_naming_the_position() {
870 let error = parse("schema_version = 1\n[project\n")
871 .expect_err("bad TOML refuses")
872 .to_string();
873 for expected in [CONFIG_PATH, "line 2", "column"] {
874 assert!(error.contains(expected), "{error}");
875 }
876 }
877
878 #[test]
879 fn an_absent_config_reads_as_none() {
880 let dir = tempfile::tempdir().expect("a target exists");
881 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
882 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
883 }
884
885 #[test]
886 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
887 let dir = tempfile::tempdir().expect("a target exists");
888 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
889 std::fs::write(
890 dir.path().join(CONFIG_PATH),
891 "schema_version = 1\n[protection]\nstrict_required_status_checks = false\n",
892 )
893 .expect("a config exists");
894 let error =
895 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
896 assert_eq!(error.exit_code(), 73);
897 assert!(
898 error
899 .to_string()
900 .contains("protection.strict_required_status_checks")
901 );
902 }
903
904 #[test]
905 fn rewrite_key_preserves_comments() {
906 let dir = tempfile::tempdir().expect("a target exists");
907 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
908 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";
909 let path = dir.path().join(CONFIG_PATH);
910 std::fs::write(&path, original).expect("a config exists");
911 rewrite_key(dir.path(), "landing.style", "lines".into()).expect("the style writes back");
912 let text = std::fs::read_to_string(&path).expect("the text reads");
913 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
914 assert_eq!(
915 load(dir.path())
916 .expect("the config reads")
917 .expect("present")
918 .landing
919 .style,
920 Some(Style::Lines)
921 );
922 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
923 .expect("an omitted table can be added");
924 assert_eq!(
925 load(dir.path())
926 .expect("reads")
927 .expect("present")
928 .project
929 .repo,
930 "acme/widget"
931 );
932 let before = std::fs::read(&path).expect("the bytes read");
933 for (key, value) in [("security.contact", "other"), ("landing.style", "unknown")] {
934 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
935 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
936 }
937 }
938}