1pub mod dev_environment;
8pub mod floors;
9pub mod migrate;
10
11use std::collections::BTreeMap;
12use std::fmt::Write as _;
13use std::path::Path;
14
15use crate::diagnostic::{Diagnostic, Reason};
16use crate::error::RkError;
17use crate::landing::{CheckoutMode, Integration, MergeForm, Style};
18use crate::profile::ReleaseMode;
19use serde::{Deserialize, Serialize};
20
21pub const CONFIG_PATH: &str = ".release-kit/config.toml";
23pub const SCHEMA_VERSION: i64 = 2;
26
27#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
29#[serde(deny_unknown_fields, default)]
30pub struct Config {
31 pub schema_version: i64,
33 pub migration: Option<String>,
37 pub project: Project,
39 pub profile: Profile,
41 pub git: Git,
43 pub capabilities: Capabilities,
45 pub security: Security,
47 pub setup: Setup,
49 pub protection: Protection,
51 pub acquisition: Option<Acquisition>,
53 pub dev_environment: Option<dev_environment::DevEnvironment>,
55 pub landing: Landing,
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
66#[serde(deny_unknown_fields, default)]
67pub struct Landing {
68 pub accepted_drift: BTreeMap<String, AcceptedDrift>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Deserialize, serde::Serialize)]
76#[serde(deny_unknown_fields)]
77pub struct AcceptedDrift {
78 pub reason: String,
80 pub sha256: String,
82}
83
84impl Default for Config {
85 fn default() -> Self {
86 Self {
87 schema_version: SCHEMA_VERSION,
88 migration: None,
89 project: Project::default(),
90 profile: Profile::default(),
91 git: Git::default(),
92 capabilities: Capabilities::default(),
93 security: Security::default(),
94 setup: Setup::default(),
95 protection: Protection::default(),
96 acquisition: None,
97 dev_environment: None,
98 landing: Landing::default(),
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
105#[serde(rename_all = "kebab-case")]
106pub enum AcquisitionSource {
107 Host,
109}
110
111impl AcquisitionSource {
112 #[must_use]
114 pub const fn as_str(self) -> &'static str {
115 match self {
116 Self::Host => "host",
117 }
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
129#[serde(deny_unknown_fields)]
130pub struct Acquisition {
131 pub source: AcquisitionSource,
133 pub minimum: String,
135}
136
137impl Acquisition {
138 #[must_use]
140 pub fn host_at_this_release() -> Self {
141 Self {
142 source: AcquisitionSource::Host,
143 minimum: env!("CARGO_PKG_VERSION").to_owned(),
144 }
145 }
146}
147
148fn is_release_version(value: &str) -> bool {
151 let core = value.split(['-', '+']).next().unwrap_or_default();
152 let parts: Vec<&str> = core.split('.').collect();
153 parts.len() == 3
154 && parts
155 .iter()
156 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
157 && value[core.len()..]
158 .chars()
159 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
160}
161
162#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
164#[serde(deny_unknown_fields, default)]
165pub struct Project {
166 pub repo: String,
169}
170
171#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
173#[serde(deny_unknown_fields, default)]
174pub struct Profile {
175 pub technologies: Option<Vec<String>>,
177 pub forge: Option<String>,
180 pub release: Release,
182}
183
184#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
186#[serde(deny_unknown_fields, default)]
187pub struct Release {
188 pub mode: Option<ReleaseMode>,
191 pub driver: Option<String>,
194 pub style: Option<Style>,
196 pub line_prefix: Option<String>,
198 pub registry: Option<crate::profile::RegistryAnswer>,
202 pub build: Option<crate::profile::BuildAnswer>,
205}
206
207#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
209#[serde(deny_unknown_fields, default)]
210pub struct Git {
211 pub trunk: Option<String>,
215 pub checkout_mode: Option<CheckoutMode>,
217 pub integration: Option<Integration>,
220 pub merge_form: Option<MergeForm>,
222}
223
224#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
226#[serde(deny_unknown_fields, default)]
227pub struct Capabilities {
228 pub nix_packaging: Option<bool>,
230 pub reporting_policy: Option<bool>,
232 pub scorecard: Option<bool>,
234 pub code_scanning: Option<String>,
238 pub workflow_guards: Option<bool>,
241}
242
243pub const TRUNK_DEFAULT: &str = "master";
245
246pub const LINE_PREFIX_DEFAULT: &str = "release/";
248
249#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
251#[serde(deny_unknown_fields, default)]
252pub struct Security {
253 pub advisories: String,
255 pub contact: Option<String>,
260 pub response: Option<String>,
263 pub scope: Option<String>,
268}
269
270pub const RESPONSE_DEFAULT: &str = "best-effort";
273
274pub const LOCAL_GITHUB_BYPASS: &str = "repository-admin";
280
281pub fn canonical_contact(raw: &str) -> Result<String, String> {
291 let trimmed = raw.trim();
292 if trimmed.chars().any(char::is_control) {
293 return Err(format!(
294 "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:?}"
295 ));
296 }
297 Ok(trimmed.to_owned())
298}
299
300pub fn canonical_scope(raw: &str) -> Result<String, String> {
312 let trimmed = raw.trim();
313 let refusal = |why: &str| {
314 format!(
315 "security.scope {why}; it is one paragraph naming what the project does not own, and empty renders nothing, found {trimmed:?}"
316 )
317 };
318 if trimmed.chars().any(|c| c.is_control() && c != '\n') {
319 return Err(refusal(
320 "carries a control character other than a line feed",
321 ));
322 }
323 if trimmed.lines().any(|line| line.trim().is_empty()) {
324 return Err(refusal("carries a blank line"));
325 }
326 if trimmed
327 .lines()
328 .any(|line| line.trim_start().starts_with('#'))
329 {
330 return Err(refusal("carries a line that opens a Markdown heading"));
331 }
332 if trimmed.lines().any(|line| {
336 let line = line.trim();
337 line.chars().all(|c| c == '=') || line.chars().all(|c| c == '-')
338 }) {
339 return Err(refusal(
340 "carries a line of = or - alone, which underlines a Markdown heading",
341 ));
342 }
343 if trimmed.contains("<!--") || trimmed.contains("-->") {
344 return Err(refusal("carries a comment delimiter, <!-- or -->"));
345 }
346 Ok(trimmed.to_owned())
347}
348
349pub fn bot_name_shape(raw: &str) -> Result<(), String> {
360 let name = raw.trim();
361 let mut chars = name.chars();
362 let shaped = chars.next().is_none_or(|first| {
363 first.is_ascii_alphanumeric()
364 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
365 });
366 if shaped && name.len() <= 255 {
367 Ok(())
368 } else {
369 Err(format!(
370 "setup.bot.name must be empty or a username: a letter or a digit, then letters, digits, '.', '_', or '-', at most 255 characters; found {name:?}"
371 ))
372 }
373}
374
375pub fn canonical_response(raw: &str) -> Result<String, String> {
387 let trimmed = raw.trim();
388 if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
389 return Ok(RESPONSE_DEFAULT.to_owned());
390 }
391 let refusal = || {
392 format!(
393 "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:?}"
394 )
395 };
396 let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
397 let plural = match unit {
398 "day" | "business day" => false,
399 "days" | "business days" => true,
400 _ => return Err(refusal()),
401 };
402 let number: u32 = count.parse().map_err(|_| refusal())?;
403 if count != number.to_string() || (number > 1) != plural {
406 return Err(refusal());
407 }
408 Ok(trimmed.to_owned())
409}
410
411#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
413#[serde(deny_unknown_fields, default)]
414pub struct Setup {
415 pub required_check: String,
417 pub retired_branches: Vec<String>,
419 pub release_lines: bool,
421 pub excluded_steps: BTreeMap<String, String>,
426 pub bot: Bot,
428}
429
430impl Default for Setup {
431 fn default() -> Self {
432 Self {
433 required_check: String::new(),
434 retired_branches: vec!["main".into(), "develop".into()],
435 release_lines: false,
436 excluded_steps: BTreeMap::new(),
437 bot: Bot::default(),
438 }
439 }
440}
441
442#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
449#[serde(deny_unknown_fields, default)]
450pub struct Bot {
451 pub app_id: String,
453 pub name: String,
456 #[serde(default, skip_serializing)]
461 pub installation_id: Option<i64>,
462}
463
464#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
466#[serde(deny_unknown_fields, default)]
467#[allow(
468 clippy::struct_excessive_bools,
469 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
470)]
471pub struct Protection {
472 pub trunk_ruleset: Option<String>,
476 pub safety_ruleset: Option<String>,
481 pub tag_ruleset: String,
483 pub lines_ruleset: String,
485 pub title_check: String,
487 pub tag_pattern: String,
489 pub bypass_actors: Vec<String>,
492 pub allowed_merge_methods: Vec<String>,
495 pub strict_required_status_checks: bool,
497 pub owned_trunk_rules: Vec<String>,
501 pub required_approving_review_count: i64,
503 pub dismiss_stale_reviews_on_push: bool,
505 pub require_code_owner_review: bool,
507 pub require_last_push_approval: bool,
509 pub github: Github,
511 pub gitlab: Gitlab,
513}
514
515impl Default for Protection {
516 fn default() -> Self {
517 Self {
518 trunk_ruleset: None,
519 safety_ruleset: None,
520 tag_ruleset: "release-tags".into(),
521 lines_ruleset: "release-lines".into(),
522 title_check: "pr-title".into(),
523 tag_pattern: "refs/tags/v*".into(),
524 bypass_actors: Vec::new(),
525 allowed_merge_methods: vec!["squash".into()],
526 strict_required_status_checks: true,
527 owned_trunk_rules: vec![
528 "deletion".into(),
529 "non_fast_forward".into(),
530 "pull_request".into(),
531 "required_status_checks".into(),
532 ],
533 required_approving_review_count: 0,
534 dismiss_stale_reviews_on_push: false,
535 require_code_owner_review: false,
536 require_last_push_approval: false,
537 github: Github::default(),
538 gitlab: Gitlab::default(),
539 }
540 }
541}
542
543impl Protection {
544 #[must_use]
547 pub fn trunk_ruleset(&self, trunk: &str) -> String {
548 self.trunk_ruleset
549 .clone()
550 .unwrap_or_else(|| format!("{trunk}-protection"))
551 }
552
553 #[must_use]
560 pub fn safety_ruleset(&self, trunk: &str) -> String {
561 self.safety_ruleset
562 .clone()
563 .unwrap_or_else(|| format!("{trunk}-safety"))
564 }
565}
566
567pub const SAFETY_RULES: [&str; 3] = ["deletion", "non_fast_forward", LINEAR_HISTORY_RULE];
577
578pub const COMMIT_GATE_CHECK: &str = "pr-commits";
582
583pub const LINEAR_HISTORY_RULE: &str = "required_linear_history";
587
588pub const REQUEST_RULES: [&str; 2] = ["pull_request", "required_status_checks"];
591
592#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
594#[serde(deny_unknown_fields, default)]
595pub struct Github {
596 pub squash_title_source: String,
599 pub squash_body_source: String,
601}
602
603impl Default for Github {
604 fn default() -> Self {
605 Self {
606 squash_title_source: "PR_TITLE".into(),
607 squash_body_source: "PR_BODY".into(),
608 }
609 }
610}
611
612#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
614#[serde(deny_unknown_fields, default)]
615pub struct Gitlab {
616 pub merge_method: String,
618 pub squash_option: String,
622 pub squash_commit_template: String,
624 pub push_access_level: i64,
629 pub merge_access_level: i64,
631}
632
633impl Default for Gitlab {
634 fn default() -> Self {
635 Self {
636 merge_method: "ff".into(),
637 squash_option: "always".into(),
638 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
639 .trim_end_matches('\n')
640 .to_owned(),
641 push_access_level: 0,
642 merge_access_level: 40,
643 }
644 }
645}
646
647pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
652 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
653 Ok(text) => text,
654 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
655 Err(error) => return Err(error.into()),
656 };
657 parse(&text).map(Some)
658}
659
660fn current_text(text: &str) -> Result<String, RkError> {
663 let raw: toml::Value =
664 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
665 match raw.get("schema_version").and_then(toml::Value::as_integer) {
666 Some(1) => migrate::to_schema_2(text),
667 _ => Ok(text.to_owned()),
668 }
669}
670
671fn release_vocabulary(raw: &toml::Value) -> Result<(), RkError> {
677 let release = raw
678 .get("profile")
679 .and_then(|profile| profile.get("release"));
680 if let Some(mode) = release.and_then(|release| release.get("mode")) {
681 let named = mode.as_str().ok_or_else(|| {
682 invalid("profile.release.mode must be a string: automatic, external, or none")
683 })?;
684 crate::profile::ReleaseMode::parse(named)
685 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
686 }
687 match release.and_then(|release| release.get("registry")) {
688 Some(registry) if !matches!(registry.as_str(), Some("crates-io" | "pypi" | "none")) => {
689 Err(invalid(format!(
690 "profile.release.registry is {registry}; it takes {}, where the release publishes",
691 crate::profile::RegistryAnswer::SHAPE
692 )))
693 }
694 _ => Ok(()),
695 }
696}
697
698pub fn build_verify_shape(raw: &str) -> Result<(), String> {
705 if raw.trim().is_empty() {
706 return Err("profile.release.build.verify is empty; it names the command that proves the unpacked package builds and installs from its own files, such as make check".to_owned());
707 }
708 if raw.chars().any(char::is_control) {
709 return Err(format!(
710 "profile.release.build.verify is one line with no control character; found {raw:?}"
711 ));
712 }
713 Ok(())
714}
715
716pub fn build_packages_shape(raw: &str) -> Result<(), String> {
722 let shaped = raw.split(' ').filter(|word| !word.is_empty()).all(|word| {
723 word.chars()
724 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '_' | ':' | '-'))
725 });
726 if shaped {
727 Ok(())
728 } else {
729 Err(format!(
730 "profile.release.build.packages is package names separated by spaces, each of letters, digits, '.', '+', '_', ':', or '-'; found {raw:?}"
731 ))
732 }
733}
734
735pub fn build_patterns_shape(key: &str, patterns: &[String]) -> Result<(), String> {
744 for pattern in patterns {
745 if pattern.trim().is_empty() || pattern.chars().any(char::is_control) {
746 return Err(format!(
747 "{key} holds gitignore-style patterns of one line each, with no control character and none empty; found {pattern:?}"
748 ));
749 }
750 crate::release_engine::packed::Declaration::parse(key, std::slice::from_ref(pattern))?;
751 }
752 Ok(())
753}
754
755fn build_fit(release: &Release) -> Result<(), RkError> {
759 let Some(build) = &release.build else {
760 return Ok(());
761 };
762 if build.command.is_some() {
763 return Err(crate::profile::retired_build_command());
764 }
765 if let Some(packages) = &build.packages {
766 build_packages_shape(packages).map_err(invalid)?;
767 }
768 if let Some(verify) = &build.verify {
769 build_verify_shape(verify).map_err(invalid)?;
770 }
771 for (key, patterns) in [
772 ("profile.release.build.include", &build.include),
773 ("profile.release.build.exclude", &build.exclude),
774 ] {
775 if let Some(patterns) = patterns {
776 build_patterns_shape(key, patterns).map_err(invalid)?;
777 }
778 }
779 if release.driver.is_some()
780 || matches!(
781 release.mode,
782 Some(ReleaseMode::External | ReleaseMode::None)
783 )
784 {
785 crate::profile::catalog::build_answer_fits(
786 release.mode.unwrap_or(ReleaseMode::Automatic),
787 release.driver.as_deref(),
788 )
789 .map_err(invalid)?;
790 }
791 Ok(())
792}
793
794fn registry_fit(release: &Release) -> Result<(), RkError> {
797 if let Some(answer) = release.registry
798 && (release.driver.is_some()
799 || matches!(
800 release.mode,
801 Some(ReleaseMode::External | ReleaseMode::None)
802 ))
803 {
804 crate::profile::catalog::registry_answer_fits(
805 release.mode.unwrap_or(ReleaseMode::Automatic),
806 release.driver.as_deref(),
807 answer,
808 )
809 .map_err(invalid)?;
810 }
811 Ok(())
812}
813
814fn parse(text: &str) -> Result<Config, RkError> {
815 let text = current_text(text)?;
816 let raw: toml::Value =
817 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
818 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
819 return Err(invalid(format!(
820 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
821 )));
822 }
823 release_vocabulary(&raw)?;
824 retired_keys(&raw)?;
825 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
826 let mut message = error.to_string();
827 if let Some(rest) = error.message().strip_prefix("unknown field `") {
828 let names: Vec<_> = rest.split('`').collect();
829 if let Some(unknown) = names.first()
830 && let Some(nearest) = names
831 .iter()
832 .skip(2)
833 .step_by(2)
834 .min_by_key(|name| distance(unknown, name))
835 {
836 let _ = write!(message, "; nearest known key: {nearest}");
837 }
838 }
839 invalid(message)
840 })?;
841 if let Some(technologies) = &config.profile.technologies {
842 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
843 }
844 if let Some(forge) = &config.profile.forge
845 && !forge.is_empty()
846 {
847 crate::profile::canonical_category(forge)
848 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
849 }
850 if let Some(driver) = &config.profile.release.driver {
851 crate::profile::canonical_category(driver)
852 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
853 }
854 if matches!(
855 config.profile.release.mode,
856 Some(ReleaseMode::External | ReleaseMode::None)
857 ) {
858 let mode = config
859 .profile
860 .release
861 .mode
862 .map_or("none", ReleaseMode::as_str);
863 for (key, present) in [
864 (
865 "profile.release.driver",
866 config.profile.release.driver.is_some(),
867 ),
868 (
869 "profile.release.style",
870 config.profile.release.style.is_some(),
871 ),
872 (
873 "profile.release.line_prefix",
874 config.profile.release.line_prefix.is_some(),
875 ),
876 ] {
877 if present {
878 return Err(invalid(format!(
879 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
880 )));
881 }
882 }
883 }
884 registry_fit(&config.profile.release)?;
885 build_fit(&config.profile.release)?;
886 if let Some(contact) = &config.security.contact {
887 canonical_contact(contact).map_err(invalid)?;
888 }
889 if let Some(response) = &config.security.response {
890 canonical_response(response).map_err(invalid)?;
891 }
892 if let Some(scope) = &config.security.scope {
893 canonical_scope(scope).map_err(invalid)?;
894 }
895 bot_name_shape(&config.setup.bot.name).map_err(invalid)?;
896 exclusions(&config.setup.excluded_steps)?;
897 accepted_drift(&config.landing.accepted_drift)?;
898 if let Some(acquisition) = &config.acquisition
899 && !is_release_version(&acquisition.minimum)
900 {
901 return Err(invalid(format!(
902 "acquisition.minimum is {}, which is no release version; write one such as {}",
903 acquisition.minimum,
904 env!("CARGO_PKG_VERSION")
905 )));
906 }
907 if let Some(answer) = &config.dev_environment {
908 answer.check().map_err(invalid)?;
909 }
910 migration_key(&config)?;
911 floors::check(&config)?;
912 Ok(config)
913}
914
915fn migration_key(config: &Config) -> Result<(), RkError> {
918 match &config.migration {
919 Some(id) if !crate::migrate::is_id(id) => Err(invalid(format!(
920 "migration is {id}, which is no migration identifier; rk migrate archive writes one such as 20261009T140205Z (target-config:an-open-migration-is-one-top-level-key)"
921 ))),
922 _ => Ok(()),
923 }
924}
925
926fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
933 for (name, reason) in excluded {
934 if name == "auto-merge" {
935 return Err(retired_auto_merge_exclusion());
936 }
937 if crate::setup::steps::spec(name).is_none() {
938 let nearest = crate::setup::steps::STEPS
939 .iter()
940 .min_by_key(|step| distance(name, step.name))
941 .map_or("", |step| step.name);
942 return Err(invalid(format!(
943 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
944 )));
945 }
946 if reason.trim().is_empty() {
947 return Err(invalid(format!(
948 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
949 )));
950 }
951 }
952 Ok(())
953}
954
955fn retired_auto_merge_exclusion() -> RkError {
959 RkError::refusal(
961 Diagnostic::new(
962 Reason::ConfigInvalid,
963 format!(
964 "{CONFIG_PATH}: setup.excluded_steps names auto-merge, which is retired: the step is now merge-on-order, and it disarms each armed release request"
965 ),
966 )
967 .expected(format!("a [setup.excluded_steps] table in {CONFIG_PATH} without auto-merge"))
968 .action(format!(
969 "remove auto-merge from setup.excluded_steps in {CONFIG_PATH} by hand, and exclude merge-on-order only where this forge cannot run it, as guidance/0.9.15.md states"
970 ))
971 .target_state("nothing was written"),
972 )
973}
974
975fn accepted_drift(entries: &BTreeMap<String, AcceptedDrift>) -> Result<(), RkError> {
981 for (destination, entry) in entries {
982 if entry.reason.trim().is_empty() {
983 return Err(invalid(format!(
984 "landing.accepted_drift names {destination} with no reason; an accepted deviation is reported with why the project must make it"
985 )));
986 }
987 let hex = entry.sha256.len() == 64
988 && entry
989 .sha256
990 .chars()
991 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c));
992 if !hex {
993 return Err(invalid(format!(
994 "landing.accepted_drift names {destination} with sha256 {:?}, which is no SHA-256 digest; write the 64 lowercase hex characters rk status prints for the file",
995 entry.sha256
996 )));
997 }
998 }
999 Ok(())
1000}
1001
1002fn retired_keys(raw: &toml::Value) -> Result<(), RkError> {
1004 if raw
1005 .get("setup")
1006 .and_then(|setup| setup.get("required_workflow"))
1007 .is_some()
1008 {
1009 return Err(retired_required_workflow());
1010 }
1011 Ok(())
1012}
1013
1014#[must_use]
1020pub fn retired_required_workflow() -> RkError {
1021 RkError::refusal(
1023 Diagnostic::new(
1024 Reason::ConfigInvalid,
1025 format!(
1026 "{CONFIG_PATH}: setup.required_workflow is retired: it woke the release gate, and no gate remains, because a release request merges only on the operator's order"
1027 ),
1028 )
1029 .expected(format!("a [setup] table in {CONFIG_PATH} without required_workflow"))
1030 .action(format!(
1031 "remove setup.required_workflow from {CONFIG_PATH} by hand, then run rk upgrade, as guidance/0.9.15.md states"
1032 ))
1033 .target_state("nothing was written"),
1034 )
1035}
1036
1037pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
1038 RkError::refusal(
1039 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
1040 .action(format!("edit {CONFIG_PATH} and retry"))
1041 .target_state("nothing was written"),
1042 )
1043}
1044
1045fn distance(left: &str, right: &str) -> usize {
1046 let mut row: Vec<_> = (0..=right.chars().count()).collect();
1047 for (i, a) in left.chars().enumerate() {
1048 let mut previous = row[0];
1049 row[0] = i + 1;
1050 for (j, b) in right.chars().enumerate() {
1051 let old = row[j + 1];
1052 row[j + 1] = (previous + usize::from(a != b))
1053 .min(row[j] + 1)
1054 .min(old + 1);
1055 previous = old;
1056 }
1057 }
1058 row.last().copied().unwrap_or(0)
1059}
1060
1061pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
1067 let bytes = render(config)?;
1068 parse(&String::from_utf8_lossy(&bytes))?;
1069 write_checked(target, &bytes)
1070}
1071
1072fn write_checked(target: &Path, bytes: &[u8]) -> Result<(), RkError> {
1075 check_writable(target)?;
1076 crate::atomic::write(&target.join(CONFIG_PATH), bytes)?;
1077 Ok(())
1078}
1079
1080fn array(values: &[String]) -> toml_edit::Value {
1081 toml_edit::Value::Array(values.iter().collect())
1082}
1083
1084fn accepted_inline(entries: &BTreeMap<String, AcceptedDrift>) -> toml_edit::Value {
1090 let mut table = toml_edit::InlineTable::new();
1091 for (destination, entry) in entries {
1092 let mut row = toml_edit::InlineTable::new();
1093 row.insert("reason", entry.reason.clone().into());
1094 row.insert("sha256", entry.sha256.clone().into());
1095 table.insert(destination, toml_edit::Value::InlineTable(row));
1096 }
1097 toml_edit::Value::InlineTable(table)
1098}
1099
1100fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
1101 let mut table = toml_edit::InlineTable::new();
1102 for (key, value) in values {
1103 table.insert(key, value.clone().into());
1104 }
1105 toml_edit::Value::InlineTable(table)
1106}
1107
1108#[allow(
1113 clippy::too_many_lines,
1114 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
1115)]
1116fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
1117 let trunk = config
1121 .git
1122 .trunk
1123 .clone()
1124 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
1125 let technologies = config
1126 .profile
1127 .technologies
1128 .clone()
1129 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
1130 let forge = config
1131 .profile
1132 .forge
1133 .clone()
1134 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
1135 let mode = config
1136 .profile
1137 .release
1138 .mode
1139 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
1140 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
1141 (
1142 "RK_CONFIG_SCHEMA_VERSION",
1143 Some(config.schema_version.into()),
1144 ),
1145 (
1146 "RK_CONFIG_PROJECT_REPO",
1147 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1148 ),
1149 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
1150 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
1151 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
1152 (
1153 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
1154 config
1155 .profile
1156 .release
1157 .driver
1158 .clone()
1159 .map(toml_edit::Value::from),
1160 ),
1161 (
1162 "RK_CONFIG_PROFILE_RELEASE_STYLE",
1163 config
1164 .profile
1165 .release
1166 .style
1167 .map(|style| style.as_str().into()),
1168 ),
1169 (
1170 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
1171 config
1172 .profile
1173 .release
1174 .line_prefix
1175 .clone()
1176 .map(toml_edit::Value::from),
1177 ),
1178 (
1179 "RK_CONFIG_PROFILE_RELEASE_REGISTRY",
1180 config
1181 .profile
1182 .release
1183 .registry
1184 .map(|answer| answer.as_str().into()),
1185 ),
1186 (
1187 "RK_CONFIG_PROFILE_RELEASE_BUILD_PACKAGES",
1188 config
1189 .profile
1190 .release
1191 .build
1192 .as_ref()
1193 .and_then(|build| build.packages.clone())
1194 .map(toml_edit::Value::from),
1195 ),
1196 (
1197 "RK_CONFIG_PROFILE_RELEASE_BUILD_INCLUDE",
1198 config
1199 .profile
1200 .release
1201 .build
1202 .as_ref()
1203 .and_then(|build| build.include.as_deref())
1204 .map(array),
1205 ),
1206 (
1207 "RK_CONFIG_PROFILE_RELEASE_BUILD_EXCLUDE",
1208 config
1209 .profile
1210 .release
1211 .build
1212 .as_ref()
1213 .and_then(|build| build.exclude.as_deref())
1214 .map(array),
1215 ),
1216 (
1217 "RK_CONFIG_PROFILE_RELEASE_BUILD_VERIFY",
1218 config
1219 .profile
1220 .release
1221 .build
1222 .as_ref()
1223 .and_then(|build| build.verify.clone())
1224 .map(toml_edit::Value::from),
1225 ),
1226 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
1227 (
1228 "RK_CONFIG_GIT_CHECKOUT_MODE",
1229 Some(
1230 config
1231 .git
1232 .checkout_mode
1233 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
1234 .as_str()
1235 .into(),
1236 ),
1237 ),
1238 (
1239 "RK_CONFIG_GIT_INTEGRATION",
1240 Some(
1241 config
1242 .git
1243 .integration
1244 .ok_or_else(|| invalid("git.integration is unresolved"))?
1245 .as_str()
1246 .into(),
1247 ),
1248 ),
1249 (
1250 "RK_CONFIG_GIT_MERGE_FORM",
1251 Some(
1252 config
1253 .git
1254 .merge_form
1255 .ok_or_else(|| invalid("git.merge_form is unresolved"))?
1256 .as_str()
1257 .into(),
1258 ),
1259 ),
1260 (
1261 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
1262 Some(
1263 config
1264 .capabilities
1265 .nix_packaging
1266 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
1267 .into(),
1268 ),
1269 ),
1270 (
1271 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
1272 Some(
1273 config
1274 .capabilities
1275 .reporting_policy
1276 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
1277 .into(),
1278 ),
1279 ),
1280 (
1281 "RK_CONFIG_CAPABILITIES_SCORECARD",
1282 Some(
1283 config
1284 .capabilities
1285 .scorecard
1286 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
1287 .into(),
1288 ),
1289 ),
1290 (
1291 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
1292 Some(
1293 config
1294 .capabilities
1295 .code_scanning
1296 .clone()
1297 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
1298 .into(),
1299 ),
1300 ),
1301 (
1302 "RK_CONFIG_CAPABILITIES_WORKFLOW_GUARDS",
1303 Some(
1304 config
1305 .capabilities
1306 .workflow_guards
1307 .ok_or_else(|| invalid("capabilities.workflow_guards is unresolved"))?
1308 .into(),
1309 ),
1310 ),
1311 (
1312 "RK_CONFIG_SECURITY_ADVISORIES",
1313 Some(config.security.advisories.clone().into()),
1314 ),
1315 (
1316 "RK_CONFIG_SECURITY_CONTACT",
1317 Some(config.security.contact.clone().unwrap_or_default().into()),
1318 ),
1319 (
1320 "RK_CONFIG_SECURITY_RESPONSE",
1321 Some(
1322 config
1323 .security
1324 .response
1325 .clone()
1326 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
1327 .into(),
1328 ),
1329 ),
1330 (
1331 "RK_CONFIG_SECURITY_SCOPE",
1332 Some(config.security.scope.clone().unwrap_or_default().into()),
1333 ),
1334 (
1335 "RK_CONFIG_SETUP_REQUIRED_CHECK",
1336 Some(config.setup.required_check.clone().into()),
1337 ),
1338 (
1339 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
1340 Some(array(&config.setup.retired_branches)),
1341 ),
1342 (
1343 "RK_CONFIG_SETUP_RELEASE_LINES",
1344 Some(config.setup.release_lines.into()),
1345 ),
1346 (
1347 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
1348 Some(inline(&config.setup.excluded_steps)),
1349 ),
1350 (
1351 "RK_CONFIG_LANDING_ACCEPTED_DRIFT",
1352 Some(accepted_inline(&config.landing.accepted_drift)),
1353 ),
1354 (
1355 "RK_CONFIG_SETUP_BOT_APP_ID",
1356 Some(config.setup.bot.app_id.clone().into()),
1357 ),
1358 (
1359 "RK_CONFIG_SETUP_BOT_NAME",
1360 Some(config.setup.bot.name.clone().into()),
1361 ),
1362 (
1363 "RK_CONFIG_ACQUISITION_SOURCE",
1364 config
1365 .acquisition
1366 .as_ref()
1367 .map(|answer| answer.source.as_str().into()),
1368 ),
1369 (
1370 "RK_CONFIG_ACQUISITION_MINIMUM",
1371 config
1372 .acquisition
1373 .as_ref()
1374 .map(|answer| answer.minimum.clone().into()),
1375 ),
1376 ];
1377 fields.extend(
1378 protection_fields(&config.protection, trunk.as_str())
1379 .into_iter()
1380 .map(|(token, value)| (token, Some(value))),
1381 );
1382 if mode == crate::profile::ReleaseMode::None {
1388 let reporting = config.capabilities.reporting_policy.unwrap_or(false);
1389 for (token, value) in &mut fields {
1390 if RELEASE_ONLY_TOKENS.contains(token)
1391 || (!reporting && SECURITY_TOKENS.contains(token))
1392 {
1393 *value = None;
1394 }
1395 }
1396 }
1397 Ok(fields)
1398}
1399
1400const RELEASE_ONLY_TOKENS: [&str; 5] = [
1406 "RK_CONFIG_PROTECTION_TAG_RULESET",
1407 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1408 "RK_CONFIG_PROTECTION_LINES_RULESET",
1409 "RK_CONFIG_SETUP_BOT_APP_ID",
1410 "RK_CONFIG_SETUP_BOT_NAME",
1411];
1412
1413const SECURITY_TOKENS: [&str; 4] = [
1416 "RK_CONFIG_SECURITY_ADVISORIES",
1417 "RK_CONFIG_SECURITY_CONTACT",
1418 "RK_CONFIG_SECURITY_RESPONSE",
1419 "RK_CONFIG_SECURITY_SCOPE",
1420];
1421
1422fn render(config: &Config) -> Result<Vec<u8>, RkError> {
1423 let fields = fields(config)?;
1424 let template = crate::embedded::BLOCKS
1425 .get_file("target-config.toml.in")
1426 .and_then(include_dir::File::contents_utf8)
1427 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
1428 let lines: Vec<&str> = template.split_inclusive('\n').collect();
1434 let dropped: Vec<bool> = lines
1435 .iter()
1436 .map(|line| {
1437 matches!(
1438 fields.iter().find(|(token, _)| line.contains(token)),
1439 Some((_, None))
1440 )
1441 })
1442 .collect();
1443 let mut bytes = Vec::new();
1444 let mut at = 0;
1445 while at < lines.len() {
1446 let line = lines[at];
1447 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
1448 let mut end = at + 1;
1449 let mut keeps = false;
1450 while end < lines.len()
1451 && !(lines[end].trim_start().starts_with('[')
1452 && lines[end].trim_end().ends_with(']'))
1453 {
1454 keeps |=
1455 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
1456 end += 1;
1457 }
1458 if !keeps {
1459 if end == lines.len() {
1464 while bytes.last() == Some(&b'\n')
1465 && bytes.len() >= 2
1466 && bytes[bytes.len() - 2] == b'\n'
1467 {
1468 bytes.pop();
1469 }
1470 }
1471 at = end;
1472 continue;
1473 }
1474 }
1475 if !dropped[at] {
1476 match fields.iter().find(|(token, _)| line.contains(token)) {
1477 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
1478 line.as_bytes(),
1479 token.as_bytes(),
1480 value.to_string().as_bytes(),
1481 )),
1482 Some((_, None)) => {}
1483 None => bytes.extend_from_slice(line.as_bytes()),
1484 }
1485 }
1486 at += 1;
1487 }
1488 Ok(bytes)
1489}
1490
1491fn protection_fields(
1492 protection: &Protection,
1493 trunk: &str,
1494) -> Vec<(&'static str, toml_edit::Value)> {
1495 vec![
1496 (
1497 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
1498 protection.trunk_ruleset(trunk).into(),
1499 ),
1500 (
1501 "RK_CONFIG_PROTECTION_SAFETY_RULESET",
1502 protection.safety_ruleset(trunk).into(),
1503 ),
1504 (
1505 "RK_CONFIG_PROTECTION_TAG_RULESET",
1506 protection.tag_ruleset.clone().into(),
1507 ),
1508 (
1509 "RK_CONFIG_PROTECTION_LINES_RULESET",
1510 protection.lines_ruleset.clone().into(),
1511 ),
1512 (
1513 "RK_CONFIG_PROTECTION_TITLE_CHECK",
1514 protection.title_check.clone().into(),
1515 ),
1516 (
1517 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1518 protection.tag_pattern.clone().into(),
1519 ),
1520 (
1521 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
1522 array(&protection.bypass_actors),
1523 ),
1524 (
1525 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
1526 array(&protection.allowed_merge_methods),
1527 ),
1528 (
1529 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
1530 protection.strict_required_status_checks.into(),
1531 ),
1532 (
1533 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
1534 array(&protection.owned_trunk_rules),
1535 ),
1536 (
1537 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
1538 protection.required_approving_review_count.into(),
1539 ),
1540 (
1541 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
1542 protection.dismiss_stale_reviews_on_push.into(),
1543 ),
1544 (
1545 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
1546 protection.require_code_owner_review.into(),
1547 ),
1548 (
1549 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
1550 protection.require_last_push_approval.into(),
1551 ),
1552 (
1553 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
1554 protection.github.squash_title_source.clone().into(),
1555 ),
1556 (
1557 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
1558 protection.github.squash_body_source.clone().into(),
1559 ),
1560 (
1561 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
1562 protection.gitlab.merge_method.clone().into(),
1563 ),
1564 (
1565 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
1566 protection.gitlab.squash_option.clone().into(),
1567 ),
1568 (
1569 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
1570 protection.gitlab.squash_commit_template.clone().into(),
1571 ),
1572 (
1573 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
1574 protection.gitlab.push_access_level.into(),
1575 ),
1576 (
1577 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
1578 protection.gitlab.merge_access_level.into(),
1579 ),
1580 ]
1581}
1582
1583const DERIVED_POLICY_KEYS: [&str; 5] = [
1594 "protection.bypass_actors",
1595 "protection.owned_trunk_rules",
1596 "protection.gitlab.push_access_level",
1597 "protection.allowed_merge_methods",
1598 "protection.gitlab.squash_option",
1599];
1600
1601const PARAMETER_KEYS: [&str; 31] = [
1603 "project.repo",
1604 "profile.technologies",
1605 "profile.forge",
1606 "profile.release.mode",
1607 "profile.release.driver",
1608 "profile.release.style",
1609 "profile.release.line_prefix",
1610 "profile.release.registry",
1611 "profile.release.build.packages",
1612 "profile.release.build.include",
1613 "profile.release.build.exclude",
1614 "profile.release.build.verify",
1615 "git.trunk",
1616 "git.checkout_mode",
1617 "git.integration",
1618 "git.merge_form",
1619 "protection.allowed_merge_methods",
1622 "protection.gitlab.squash_option",
1623 "protection.bypass_actors",
1627 "protection.owned_trunk_rules",
1628 "protection.gitlab.push_access_level",
1629 "capabilities.nix_packaging",
1630 "capabilities.reporting_policy",
1631 "capabilities.scorecard",
1632 "capabilities.code_scanning",
1633 "capabilities.workflow_guards",
1634 "security.contact",
1635 "security.response",
1636 "security.scope",
1637 "setup.required_check",
1639 "schema_version",
1640];
1641
1642pub fn with_acquisition(target: &Path, answer: &Acquisition) -> Result<(String, bool), RkError> {
1654 let path = target.join(CONFIG_PATH);
1655 let text = match std::fs::read_to_string(&path) {
1656 Ok(text) => text,
1657 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1658 Err(error) => return Err(error.into()),
1659 };
1660 if text.is_empty() {
1661 let template = crate::embedded::BLOCKS
1662 .get_file("acquisition-config.toml.in")
1663 .and_then(include_dir::File::contents_utf8)
1664 .ok_or_else(|| invalid("the binary lacks its acquisition template"))?;
1665 let mut fresh = template.as_bytes().to_vec();
1666 for (token, value) in [
1667 (
1668 "RK_CONFIG_SCHEMA_VERSION",
1669 toml_edit::Value::from(SCHEMA_VERSION),
1670 ),
1671 (
1672 "RK_CONFIG_ACQUISITION_SOURCE",
1673 toml_edit::Value::from(answer.source.as_str()),
1674 ),
1675 (
1676 "RK_CONFIG_ACQUISITION_MINIMUM",
1677 toml_edit::Value::from(answer.minimum.clone()),
1678 ),
1679 ] {
1680 fresh =
1681 crate::landing::substitute(&fresh, token.as_bytes(), value.to_string().as_bytes());
1682 }
1683 let fresh = String::from_utf8(fresh).map_err(|error| invalid(error.to_string()))?;
1684 parse(&fresh)?;
1685 return Ok((fresh, true));
1686 }
1687 let held = parse(&text)?;
1688 if held.acquisition.as_ref() == Some(answer) {
1689 return Ok((text, false));
1690 }
1691 let text = current_text(&text)?;
1692 let mut document = text
1693 .parse::<toml_edit::DocumentMut>()
1694 .map_err(|error| invalid(error.to_string()))?;
1695 if !document.contains_table("acquisition") {
1696 let mut table = toml_edit::Table::new();
1697 table.decor_mut().set_prefix("\n");
1698 document.insert("acquisition", toml_edit::Item::Table(table));
1699 }
1700 let table = document
1701 .get_mut("acquisition")
1702 .and_then(toml_edit::Item::as_table_mut)
1703 .ok_or_else(|| invalid("acquisition must be a table"))?;
1704 for (key, value, note) in [
1705 (
1706 "source",
1707 answer.source.as_str().to_owned(),
1708 " P: host where every host installs rk itself; absent where a manager file pins it",
1709 ),
1710 (
1711 "minimum",
1712 answer.minimum.clone(),
1713 " P: the lowest rk release a host may run against this project",
1714 ),
1715 ] {
1716 let kept = table
1717 .get(key)
1718 .and_then(toml_edit::Item::as_value)
1719 .map(|held| held.decor().clone());
1720 let mut value = toml_edit::Value::from(value);
1721 match kept {
1722 Some(decor) => *value.decor_mut() = decor,
1723 None => value.decor_mut().set_suffix(format!(" #{note}")),
1724 }
1725 table.insert(key, toml_edit::Item::Value(value));
1726 }
1727 let next = document.to_string();
1728 parse(&next)?;
1729 Ok((next, true))
1730}
1731
1732impl Config {
1733 #[must_use]
1736 pub fn states_only_a_migration(&self) -> bool {
1737 self.migration.is_some()
1738 && Self {
1739 migration: None,
1740 ..self.clone()
1741 } == Self::default()
1742 }
1743}
1744
1745pub fn with_migration(text: &str, id: Option<&str>) -> Result<String, RkError> {
1752 let mut document = text
1753 .parse::<toml_edit::DocumentMut>()
1754 .map_err(|error| invalid(error.to_string()))?;
1755 let root = document.as_table_mut();
1756 match id {
1757 Some(id) => {
1758 root.insert("migration", toml_edit::value(id));
1759 root.sort_values_by(|a, _, b, _| {
1761 let rank = |key: &toml_edit::Key| match key.get() {
1762 "schema_version" => 0,
1763 "migration" => 1,
1764 _ => 2,
1765 };
1766 rank(a).cmp(&rank(b))
1767 });
1768 }
1769 None => {
1770 root.remove("migration");
1771 }
1772 }
1773 let next = document.to_string();
1774 parse(&next)?;
1775 Ok(next)
1776}
1777
1778pub fn check_writable(target: &Path) -> Result<(), RkError> {
1788 let file = target.join(CONFIG_PATH);
1789 for (path, directory) in [(file.parent(), true), (Some(file.as_path()), false)] {
1790 let Some(path) = path else { continue };
1791 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1792 continue;
1793 };
1794 let kind = metadata.file_type();
1795 let fits = if directory {
1796 kind.is_dir()
1797 } else {
1798 kind.is_file()
1799 };
1800 if !fits {
1801 return Err(RkError::refusal(
1802 Diagnostic::new(
1803 Reason::StateDrift,
1804 format!(
1805 "{} is a symlink or not a regular {}, so a configuration write would not stay in the target",
1806 path.display(),
1807 if directory { "directory" } else { "file" }
1808 ),
1809 )
1810 .expected(format!("{CONFIG_PATH} as a regular file in a regular directory"))
1811 .action("replace the symlink with the directory or file it stands for")
1812 .target_state("unchanged"),
1813 ));
1814 }
1815 }
1816 Ok(())
1817}
1818
1819pub fn set_migration(target: &Path, id: Option<&str>) -> Result<(), RkError> {
1827 check_writable(target)?;
1828 let path = target.join(CONFIG_PATH);
1829 let text = match std::fs::read_to_string(&path) {
1830 Ok(text) => text,
1831 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1832 if id.is_none() {
1833 return Ok(());
1834 }
1835 format!("schema_version = {SCHEMA_VERSION}\n")
1836 }
1837 Err(error) => return Err(error.into()),
1838 };
1839 let next = with_migration(¤t_text(&text)?, id)?;
1840 write_checked(target, next.as_bytes())
1841}
1842
1843pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
1849 let path = target.join(CONFIG_PATH);
1850 let text = std::fs::read_to_string(&path)?;
1851 let next = rewrite_text(&text, key, Some(value))?;
1852 write_checked(target, next.as_bytes())
1853}
1854
1855fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
1858 rewrite_all(text, vec![(key, value)])
1859}
1860
1861fn rewrite_all(
1869 text: &str,
1870 values: Vec<(&str, Option<toml_edit::Value>)>,
1871) -> Result<String, RkError> {
1872 for (key, _) in &values {
1873 if !PARAMETER_KEYS.contains(key) {
1874 return Err(invalid(format!("{key} is not a landing parameter")));
1875 }
1876 }
1877 parse(text)?;
1878 let text = current_text(text)?;
1879 let mut document = text
1880 .parse::<toml_edit::DocumentMut>()
1881 .map_err(|error| invalid(error.to_string()))?;
1882 let mut changed = false;
1883 let mut emptied: Vec<&str> = Vec::new();
1886 for (key, value) in values {
1887 let removing = value.is_none();
1888 let applied = apply_key(&mut document, key, value)?;
1889 changed |= applied;
1890 if applied
1891 && removing
1892 && let Some(parent) = key.split('.').next()
1893 {
1894 emptied.push(parent);
1895 }
1896 }
1897 changed |= prune_empty_tables(&mut document, &emptied);
1898 changed |= migrate::refresh_comments(&mut document);
1905 if !changed {
1906 return Ok(text);
1907 }
1908 let next = document.to_string();
1909 parse(&next)?;
1910 Ok(next)
1911}
1912
1913pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1932 let order: Vec<String> = document
1933 .as_table()
1934 .iter()
1935 .map(|(key, _)| key.to_owned())
1936 .collect();
1937 let mut changed = false;
1938 for (index, name) in order.iter().enumerate() {
1939 if !names.contains(&name.as_str())
1940 || !document
1941 .get(name)
1942 .and_then(toml_edit::Item::as_table)
1943 .is_some_and(toml_edit::Table::is_empty)
1944 {
1945 continue;
1946 }
1947 let carried = document
1948 .get(name)
1949 .and_then(toml_edit::Item::as_table)
1950 .and_then(|table| carried_comment(table.decor()));
1951 document.remove(name);
1952 changed = true;
1953 let Some(carried) = carried else { continue };
1954 let next = order
1958 .iter()
1959 .skip(index + 1)
1960 .find(|name| {
1961 document
1962 .get(name)
1963 .and_then(toml_edit::Item::as_table)
1964 .is_some_and(|table| !table.is_implicit())
1965 })
1966 .cloned();
1967 if let Some(next) = next
1968 && let Some(table) = document
1969 .get_mut(&next)
1970 .and_then(toml_edit::Item::as_table_mut)
1971 {
1972 let existing = table
1973 .decor()
1974 .prefix()
1975 .and_then(toml_edit::RawString::as_str)
1976 .unwrap_or_default()
1977 .trim_start_matches('\n')
1978 .to_owned();
1979 table
1980 .decor_mut()
1981 .set_prefix(format!("\n{carried}{existing}"));
1982 } else {
1983 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1984 if !trailing.is_empty() && !trailing.ends_with('\n') {
1985 trailing.push('\n');
1986 }
1987 trailing.push_str(&carried);
1988 document.set_trailing(trailing);
1989 }
1990 }
1991 changed
1992}
1993
1994fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
2001 let mut lines = String::new();
2002 for raw in [decor.prefix(), decor.suffix()] {
2003 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
2004 continue;
2005 };
2006 for line in text
2007 .lines()
2008 .map(str::trim)
2009 .filter(|line| line.starts_with('#'))
2010 .filter(|line| !is_template_comment(line))
2011 {
2012 lines.push_str(line);
2013 lines.push('\n');
2014 }
2015 }
2016 (!lines.is_empty()).then_some(lines)
2017}
2018
2019fn is_template_comment(line: &str) -> bool {
2028 let rest = line.trim_start_matches('#').trim_start();
2029 ["P:", "N:", "F:"]
2030 .iter()
2031 .any(|marker| rest.starts_with(marker))
2032}
2033
2034pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
2042 let first = document
2043 .as_table()
2044 .iter()
2045 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
2046 .map(|(name, _)| name.to_owned());
2047 if let Some(first) = first
2048 && let Some(table) = document
2049 .get_mut(&first)
2050 .and_then(toml_edit::Item::as_table_mut)
2051 {
2052 let existing = table
2053 .decor()
2054 .prefix()
2055 .and_then(toml_edit::RawString::as_str)
2056 .unwrap_or_default()
2057 .trim_start_matches('\n')
2058 .to_owned();
2059 table
2060 .decor_mut()
2061 .set_prefix(format!("\n{carried}{existing}"));
2062 return;
2063 }
2064 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
2065 if !trailing.is_empty() && !trailing.ends_with('\n') {
2066 trailing.push('\n');
2067 }
2068 trailing.push_str(carried);
2069 document.set_trailing(trailing);
2070}
2071
2072pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
2078 let (name, _) = table.get_key_value(key)?;
2079 carried_comment(name.leaf_decor())
2080}
2081
2082pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
2084 let Some(mut name) = table.key_mut(key) else {
2085 return;
2086 };
2087 let decor = name.leaf_decor_mut();
2088 let existing = decor
2089 .prefix()
2090 .and_then(toml_edit::RawString::as_str)
2091 .unwrap_or_default()
2092 .trim_start_matches('\n')
2093 .to_owned();
2094 decor.set_prefix(format!("\n{carried}{existing}"));
2095}
2096
2097#[must_use]
2099pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
2100 known
2101 .iter()
2102 .min_by_key(|name| distance(unknown, name))
2103 .copied()
2104}
2105
2106pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
2109 let carried = carried_comment(table.decor())?;
2110 table.decor_mut().set_prefix("\n");
2111 Some(carried)
2112}
2113
2114pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
2125 let carried = table.get_key_value(key).and_then(|(name, item)| {
2126 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
2127 if let Some(value) = item.as_value()
2128 && let Some(more) = carried_comment(value.decor())
2129 {
2130 lines.push_str(&more);
2131 }
2132 (!lines.is_empty()).then_some(lines)
2133 });
2134 let removed = table.remove(key).is_some();
2135 if let Some(carried) = carried {
2136 let existing = table
2137 .decor()
2138 .prefix()
2139 .and_then(toml_edit::RawString::as_str)
2140 .unwrap_or_default()
2141 .trim_start_matches('\n')
2142 .to_owned();
2143 table
2144 .decor_mut()
2145 .set_prefix(format!("\n{carried}{existing}"));
2146 }
2147 removed
2148}
2149
2150fn apply_key(
2153 document: &mut toml_edit::DocumentMut,
2154 key: &str,
2155 value: Option<toml_edit::Value>,
2156) -> Result<bool, RkError> {
2157 let segments: Vec<&str> = key.split('.').collect();
2158 let Some((last, parents)) = segments.split_last() else {
2161 return Err(invalid(format!("{key} names no key")));
2162 };
2163 let Some(mut value) = value else {
2164 let mut item = document.as_item_mut();
2165 for segment in parents {
2166 if item.get(segment).is_none() {
2167 return Ok(false);
2168 }
2169 item = &mut item[segment];
2170 }
2171 let removed = item
2172 .as_table_mut()
2173 .is_some_and(|table| take_comments(table, last));
2174 return Ok(removed);
2175 };
2176 let mut item = document.as_item_mut();
2177 for segment in parents {
2178 if item.get(segment).is_none() {
2179 let mut table = toml_edit::Table::new();
2180 table.set_implicit(true);
2181 item[segment] = toml_edit::Item::Table(table);
2182 }
2183 item = &mut item[segment];
2184 }
2185 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
2186 if same_value(old, &value) {
2187 return Ok(false);
2188 }
2189 *value.decor_mut() = old.decor().clone();
2190 } else if let Some(comment) = migrate::template_comment(&segments) {
2191 value.decor_mut().set_suffix(comment);
2197 }
2198 item[last] = toml_edit::Item::Value(value);
2199 Ok(true)
2200}
2201
2202fn same_value(old: &toml_edit::Value, new: &toml_edit::Value) -> bool {
2206 let parsed = |value: &toml_edit::Value| {
2207 format!("v = {}", value.to_string().trim()).parse::<toml::Table>()
2208 };
2209 matches!((parsed(old), parsed(new)), (Ok(old), Ok(new)) if old == new)
2210}
2211
2212#[derive(Debug, Clone, serde::Serialize)]
2214pub struct Plan {
2215 pub action: &'static str,
2217 pub changes: Vec<String>,
2219 pub content: String,
2221}
2222
2223impl Plan {
2224 pub fn new(
2229 target: &Path,
2230 params: &crate::landing::Params,
2231 existing: Option<&Config>,
2232 record: Option<&crate::landing::manifest::Manifest>,
2233 ) -> Result<Self, RkError> {
2234 let text = existing
2235 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
2236 .transpose()?;
2237 Self::compose(text.as_deref(), params, existing, record)
2238 }
2239
2240 pub fn compose(
2247 text: Option<&str>,
2248 params: &crate::landing::Params,
2249 existing: Option<&Config>,
2250 record: Option<&crate::landing::manifest::Manifest>,
2251 ) -> Result<Self, RkError> {
2252 let mut resolved = existing.cloned().unwrap_or_default();
2253 resolved.schema_version = SCHEMA_VERSION;
2254 params.repo().clone_into(&mut resolved.project.repo);
2255 resolved.profile = Profile {
2256 technologies: Some(params.technologies().to_vec()),
2257 forge: Some(params.forge().unwrap_or_default().to_owned()),
2258 release: Release {
2259 mode: Some(params.release_mode()),
2260 driver: params.driver().map(str::to_owned),
2261 style: params.style(),
2262 line_prefix: params.profile().release.line_prefix.clone(),
2263 registry: params.profile().release.registry,
2264 build: params.profile().release.build.clone(),
2265 },
2266 };
2267 resolved.git = Git {
2268 trunk: Some(params.trunk().to_owned()),
2269 checkout_mode: Some(params.checkout_mode()),
2270 integration: Some(params.integration()),
2271 merge_form: Some(params.merge_form()),
2272 };
2273 resolved.protection = with_merge_form(
2274 &protection_for(
2275 &resolved.protection,
2276 params.integration(),
2277 existing.is_none(),
2278 ),
2279 params.merge_form(),
2280 );
2281 resolved.capabilities = Capabilities {
2282 nix_packaging: Some(params.nix_packaging()),
2283 reporting_policy: Some(params.reporting_policy()),
2284 scorecard: Some(params.scorecard()),
2285 code_scanning: Some(
2286 params
2287 .code_scanning()
2288 .map_or("off", crate::landing::Provider::as_str)
2289 .to_owned(),
2290 ),
2291 workflow_guards: Some(params.workflow_guards()),
2292 };
2293 if params.release_mode() != crate::profile::ReleaseMode::None || params.reporting_policy() {
2297 resolved.security.contact = Some(params.security_contact().to_owned());
2298 resolved.security.response = Some(params.security_response().to_owned());
2299 resolved.security.scope = Some(params.security_scope().to_owned());
2300 }
2301 params
2302 .required_check()
2303 .clone_into(&mut resolved.setup.required_check);
2304 let bare = existing.is_some_and(Config::states_only_a_migration);
2309 let content = if let Some(text) = text.filter(|_| existing.is_some() && !bare) {
2310 rewrite_all(text, parameter_values(&resolved))?
2311 } else {
2312 let rendered =
2313 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?;
2314 with_migration(&rendered, resolved.migration.as_deref())?
2315 };
2316 parse(&content)?;
2317 Ok(Self {
2318 action: if existing.is_some() {
2319 "updated"
2320 } else {
2321 "added"
2322 },
2323 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
2324 content,
2325 })
2326 }
2327
2328 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
2334 write_checked(target, self.content.as_bytes())
2335 }
2336}
2337
2338#[allow(
2340 clippy::too_many_lines,
2341 reason = "one push per class P key, in the order the keys are written, and splitting it would hide that every key projects the same way"
2342)]
2343fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
2344 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
2345 "project.repo",
2346 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
2347 )];
2348 if let Some(list) = &config.profile.technologies {
2349 values.push(("profile.technologies", Some(array(list))));
2350 }
2351 if let Some(forge) = config.profile.forge.clone() {
2352 values.push(("profile.forge", Some(forge.into())));
2353 }
2354 if let Some(mode) = config.profile.release.mode {
2355 values.push(("profile.release.mode", Some(mode.as_str().into())));
2356 values.push((
2357 "profile.release.driver",
2358 config
2359 .profile
2360 .release
2361 .driver
2362 .clone()
2363 .map(toml_edit::Value::from),
2364 ));
2365 values.push((
2366 "profile.release.style",
2367 config
2368 .profile
2369 .release
2370 .style
2371 .map(|style| style.as_str().into()),
2372 ));
2373 values.push((
2374 "profile.release.line_prefix",
2375 config
2376 .profile
2377 .release
2378 .line_prefix
2379 .clone()
2380 .map(toml_edit::Value::from),
2381 ));
2382 values.push((
2383 "profile.release.registry",
2384 config
2385 .profile
2386 .release
2387 .registry
2388 .map(|answer| answer.as_str().into()),
2389 ));
2390 let build = config.profile.release.build.as_ref();
2391 values.push((
2392 "profile.release.build.packages",
2393 build
2394 .and_then(|build| build.packages.clone())
2395 .map(toml_edit::Value::from),
2396 ));
2397 values.push((
2398 "profile.release.build.include",
2399 build.and_then(|build| build.include.as_deref()).map(array),
2400 ));
2401 values.push((
2402 "profile.release.build.exclude",
2403 build.and_then(|build| build.exclude.as_deref()).map(array),
2404 ));
2405 values.push((
2406 "profile.release.build.verify",
2407 build
2408 .and_then(|build| build.verify.clone())
2409 .map(toml_edit::Value::from),
2410 ));
2411 }
2412 if let Some(trunk) = config.git.trunk.clone() {
2413 values.push(("git.trunk", Some(trunk.into())));
2414 }
2415 if let Some(mode) = config.git.checkout_mode {
2416 values.push(("git.checkout_mode", Some(mode.as_str().into())));
2417 }
2418 if let Some(mode) = config.git.integration {
2419 values.push(("git.integration", Some(mode.as_str().into())));
2420 let mut bypass = toml_edit::Array::new();
2424 for actor in &config.protection.bypass_actors {
2425 bypass.push(actor.as_str());
2426 }
2427 values.push((
2428 "protection.bypass_actors",
2429 Some(toml_edit::Value::Array(bypass)),
2430 ));
2431 let mut rules = toml_edit::Array::new();
2432 for rule in &config.protection.owned_trunk_rules {
2433 rules.push(rule.as_str());
2434 }
2435 values.push((
2436 "protection.owned_trunk_rules",
2437 Some(toml_edit::Value::Array(rules)),
2438 ));
2439 values.push((
2440 "protection.gitlab.push_access_level",
2441 Some(config.protection.gitlab.push_access_level.into()),
2442 ));
2443 }
2444 if let Some(form) = config.git.merge_form {
2445 values.push(("git.merge_form", Some(form.as_str().into())));
2446 let mut methods = toml_edit::Array::new();
2451 for method in &config.protection.allowed_merge_methods {
2452 methods.push(method.as_str());
2453 }
2454 values.push((
2455 "protection.allowed_merge_methods",
2456 Some(toml_edit::Value::Array(methods)),
2457 ));
2458 values.push((
2459 "protection.gitlab.squash_option",
2460 Some(config.protection.gitlab.squash_option.clone().into()),
2461 ));
2462 }
2463 if let Some(value) = config.capabilities.nix_packaging {
2464 values.push(("capabilities.nix_packaging", Some(value.into())));
2465 }
2466 if let Some(value) = config.capabilities.reporting_policy {
2467 values.push(("capabilities.reporting_policy", Some(value.into())));
2468 }
2469 if let Some(value) = config.capabilities.scorecard {
2470 values.push(("capabilities.scorecard", Some(value.into())));
2471 }
2472 if let Some(value) = config.capabilities.code_scanning.clone() {
2473 values.push(("capabilities.code_scanning", Some(value.into())));
2474 }
2475 if let Some(value) = config.capabilities.workflow_guards {
2476 values.push(("capabilities.workflow_guards", Some(value.into())));
2477 }
2478 if let Some(value) = config.security.contact.clone() {
2481 values.push(("security.contact", Some(value.into())));
2482 }
2483 if let Some(value) = config.security.response.clone() {
2484 values.push(("security.response", Some(value.into())));
2485 }
2486 if let Some(value) = config.security.scope.clone() {
2487 values.push(("security.scope", Some(value.into())));
2488 }
2489 values.push((
2492 "setup.required_check",
2493 Some(config.setup.required_check.clone().into()),
2494 ));
2495 values
2496}
2497
2498#[must_use]
2500pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
2501 let recorded = {
2502 let params = crate::landing::Params::from_record(record);
2503 Plan::compose(None, ¶ms, None, None)
2504 .ok()
2505 .and_then(|plan| parse(&plan.content).ok())
2506 };
2507 let Some(recorded) = recorded else {
2508 return Vec::new();
2509 };
2510 let render = |value: &Option<toml_edit::Value>| {
2511 value.as_ref().map_or_else(
2512 || "<absent>".to_owned(),
2513 |value| value.to_string().trim().to_owned(),
2514 )
2515 };
2516 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
2517 .iter()
2518 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2519 .map(|(key, value)| (*key, render(value)))
2520 .collect();
2521 let mut keys: Vec<String> = parameter_values(config)
2522 .into_iter()
2523 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2524 .map(|(key, value)| (key, render(&value)))
2525 .filter(|(key, value)| {
2526 baseline
2527 .iter()
2528 .any(|(other, old)| key == other && value != old)
2529 })
2530 .map(|(key, _)| key.to_owned())
2531 .collect();
2532 if let Some(answer) = &config.dev_environment
2536 && answer.clone().answered() != record.parameters.dev_environment
2537 {
2538 keys.push("dev_environment".to_owned());
2539 }
2540 keys
2541}
2542
2543#[must_use]
2551pub(crate) fn local_protection() -> Protection {
2552 const MAINTAINER: i64 = 40;
2554 Protection {
2555 bypass_actors: vec![LOCAL_GITHUB_BYPASS.into()],
2556 gitlab: Gitlab {
2557 push_access_level: MAINTAINER,
2558 ..Gitlab::default()
2559 },
2560 ..Protection::default()
2561 }
2562}
2563
2564#[must_use]
2571pub const fn merge_values(form: MergeForm) -> (&'static str, &'static str) {
2572 match form {
2573 MergeForm::Squash => ("squash", "always"),
2574 MergeForm::Rebase => ("rebase", "never"),
2575 }
2576}
2577
2578#[must_use]
2583pub fn with_merge_form(policy: &Protection, form: MergeForm) -> Protection {
2584 let (method, squash_option) = merge_values(form);
2585 let mut next = policy.clone();
2586 next.allowed_merge_methods = vec![method.to_owned()];
2587 squash_option.clone_into(&mut next.gitlab.squash_option);
2588 next
2589}
2590
2591#[must_use]
2594pub(crate) fn legacy_local_protection(policy: &Protection) -> bool {
2595 policy.bypass_actors.is_empty()
2596 && policy.owned_trunk_rules == ["deletion", "non_fast_forward"]
2597 && policy.gitlab.push_access_level >= 40
2598}
2599
2600#[must_use]
2622fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
2623 let forge = Protection::default();
2624 let local = local_protection();
2625 let tuple = |policy: &Protection| {
2626 (
2627 policy.bypass_actors.clone(),
2628 policy.owned_trunk_rules.clone(),
2629 policy.gitlab.push_access_level,
2630 )
2631 };
2632 let taken = fresh
2633 || tuple(held) == tuple(&forge)
2634 || tuple(held) == tuple(&local)
2635 || legacy_local_protection(held);
2636 if !taken {
2637 return held.clone();
2638 }
2639 let mut next = held.clone();
2640 let source = match integration {
2641 Integration::Forge => forge,
2642 Integration::Local => local,
2643 };
2644 next.bypass_actors = source.bypass_actors;
2645 next.owned_trunk_rules = source.owned_trunk_rules;
2646 next.gitlab.push_access_level = source.gitlab.push_access_level;
2647 next
2648}
2649
2650pub fn trunk_of(target: &Path) -> Result<String, RkError> {
2655 Ok(load(target)?
2656 .and_then(|config| config.git.trunk)
2657 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
2658}
2659
2660pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
2665 Ok(load(target)?
2666 .and_then(|config| config.profile.release.line_prefix)
2667 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
2668}
2669
2670#[cfg(test)]
2671mod tests {
2672 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, set_migration, trunk_of, write};
2673 use crate::landing::{CheckoutMode, Integration, MergeForm, Style};
2674 use crate::profile::ReleaseMode;
2675
2676 fn resolved_defaults() -> Config {
2679 Config {
2680 project: super::Project {
2681 repo: "acme/widget".into(),
2682 },
2683 profile: super::Profile {
2684 technologies: Some(vec!["rust".into()]),
2685 forge: Some("github".into()),
2686 release: super::Release {
2687 mode: Some(ReleaseMode::Automatic),
2688 driver: Some("rust".into()),
2689 style: Some(Style::Trunk),
2690 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
2691 registry: None,
2692 build: None,
2693 },
2694 },
2695 git: super::Git {
2696 trunk: Some(super::TRUNK_DEFAULT.into()),
2697 checkout_mode: Some(CheckoutMode::LinkedWorktree),
2698 integration: Some(Integration::Local),
2699 merge_form: Some(MergeForm::Squash),
2700 },
2701 capabilities: super::Capabilities {
2702 nix_packaging: Some(false),
2703 reporting_policy: Some(true),
2704 scorecard: Some(false),
2705 code_scanning: Some("off".to_owned()),
2706 workflow_guards: Some(false),
2707 },
2708 security: super::Security {
2711 contact: Some(String::new()),
2712 response: Some(super::RESPONSE_DEFAULT.into()),
2713 scope: Some(String::new()),
2714 ..super::Security::default()
2715 },
2716 protection: super::Protection {
2719 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
2720 safety_ruleset: Some(format!("{}-safety", super::TRUNK_DEFAULT)),
2721 ..super::local_protection()
2722 },
2723 ..Config::default()
2724 }
2725 }
2726
2727 #[test]
2730 fn a_symlinked_configuration_path_refuses_every_writer() {
2731 let outside = tempfile::tempdir().expect("an outside directory exists");
2732 let text = "schema_version = 2\n";
2733 std::fs::write(outside.path().join("config.toml"), text).expect("writes");
2734 let linked_dir = tempfile::tempdir().expect("a tempdir");
2735 std::os::unix::fs::symlink(outside.path(), linked_dir.path().join(".release-kit"))
2736 .expect("links the directory");
2737 let linked_file = tempfile::tempdir().expect("a tempdir");
2738 std::fs::create_dir_all(linked_file.path().join(".release-kit")).expect("mkdir");
2739 std::os::unix::fs::symlink(
2740 outside.path().join("config.toml"),
2741 linked_file.path().join(CONFIG_PATH),
2742 )
2743 .expect("links the file");
2744 for dir in [&linked_dir, &linked_file] {
2745 let target = dir.path();
2746 let refusals = [
2747 write(target, &resolved_defaults()).expect_err("write refuses"),
2748 rewrite_key(target, "git.trunk", "main".into()).expect_err("rewrite refuses"),
2749 set_migration(target, Some("20261009T140205Z")).expect_err("the key refuses"),
2750 ];
2751 for error in refusals {
2752 assert_eq!(error.exit_code(), 73, "{error}");
2753 assert!(error.to_string().contains("symlink"), "{error}");
2754 }
2755 }
2756 assert_eq!(
2757 std::fs::read_to_string(outside.path().join("config.toml")).expect("reads"),
2758 text
2759 );
2760 }
2761
2762 #[test]
2763 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
2764 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
2765 let explicit = parse(
2766 "schema_version = 2\n[git]\ncheckout_mode = 'linked-worktree'\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk'\n[capabilities]\nnix_packaging = false\nreporting_policy = true\nscorecard = false\ncode_scanning = 'off'\n",
2767 )
2768 .expect("explicit defaults parse");
2769 assert_eq!(omitted.git, super::Git::default());
2770 assert_eq!(omitted.capabilities, super::Capabilities::default());
2771 assert_eq!(
2772 explicit.git.checkout_mode,
2773 Some(CheckoutMode::LinkedWorktree)
2774 );
2775 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
2776 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
2777 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
2778 assert_eq!(explicit.capabilities.scorecard, Some(false));
2779 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
2780 assert_ne!(omitted, explicit);
2781 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
2783 .expect("the older spelling reads");
2784 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
2785 }
2786
2787 #[test]
2791 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
2792 let dir = tempfile::tempdir().expect("a tempdir");
2793 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
2794 std::fs::write(
2795 dir.path().join(CONFIG_PATH),
2796 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
2797 )
2798 .expect("the config writes");
2799 let held = load(dir.path())
2800 .expect("the config reads")
2801 .expect("it is present");
2802 assert_eq!(held.setup.bot.app_id, "123");
2803 assert_eq!(
2804 held.setup.bot.installation_id,
2805 Some(0),
2806 "the key parses; nothing reads it"
2807 );
2808 }
2809
2810 #[test]
2812 fn a_schema_1_config_migrates_into_its_domains() {
2813 let held = parse(
2814 "schema_version = 1\n[project]\nrepo = 'acme/widget'\nforge = 'gitlab'\ntech = 'bash'\ntrunk = 'main'\n[landing]\nworkflow = 'branches'\nstyle = 'lines'\nnix = false\n[setup]\nline_prefix = 'stable/'\n",
2815 )
2816 .expect("a schema 1 file reads");
2817 assert_eq!(held.schema_version, 2);
2818 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
2819 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
2820 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
2821 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
2822 assert_eq!(held.profile.release.style, Some(Style::Lines));
2823 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
2824 assert_eq!(held.git.trunk.as_deref(), Some("main"));
2825 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
2826 assert_eq!(held.capabilities.nix_packaging, Some(false));
2827 assert_eq!(held.capabilities.reporting_policy, Some(true));
2828 }
2829
2830 #[test]
2832 fn every_invalid_release_state_names_its_key() {
2833 for (text, key) in [
2834 (
2835 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
2836 "profile.release.style",
2837 ),
2838 (
2839 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
2840 "profile.release.driver",
2841 ),
2842 (
2843 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
2844 "profile.release.line_prefix",
2845 ),
2846 (
2847 "[profile]\ntechnologies = ['rust', 'rust']\n",
2848 "profile.technologies",
2849 ),
2850 (
2851 "[profile]\ntechnologies = ['Rust!']\n",
2852 "profile.technologies",
2853 ),
2854 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
2855 (
2856 "[profile.release]\nmode = 'manual'\n",
2857 "profile.release.mode",
2858 ),
2859 ] {
2860 let error = parse(&format!("schema_version = 2\n{text}"))
2861 .expect_err("an invalid release state refuses")
2862 .to_string();
2863 assert!(error.contains(key), "{key}: {error}");
2864 }
2865 }
2866
2867 #[test]
2871 fn a_newly_written_key_takes_the_templates_comment() {
2872 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
2873 let next = super::rewrite_text(
2874 text,
2875 "git.integration",
2876 Some(toml_edit::Value::from("forge")),
2877 )
2878 .expect("the key writes");
2879 assert!(
2880 next.contains("integration = \"forge\" # P: local or forge"),
2881 "{next}"
2882 );
2883 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
2885 .expect("the key writes");
2886 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
2887 }
2888
2889 #[test]
2890 fn the_landed_config_template_round_trips() {
2891 let dir = tempfile::tempdir().expect("a target exists");
2892 let mut config = Config::default();
2893 config.project.repo = "acme/nested/widget".into();
2894 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
2895 config.profile.forge = Some("gitlab".into());
2896 config.profile.release = super::Release {
2897 mode: Some(ReleaseMode::Automatic),
2898 driver: Some("bash".into()),
2899 style: Some(Style::Lines),
2900 line_prefix: Some("stable/".into()),
2901 registry: None,
2902 build: None,
2903 };
2904 config.git.trunk = Some("main".into());
2905 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
2906 config.git.integration = Some(Integration::Forge);
2907 config.git.merge_form = Some(MergeForm::Rebase);
2908 config.protection = super::with_merge_form(&config.protection, MergeForm::Rebase);
2909 config.capabilities.nix_packaging = Some(true);
2910 config.capabilities.reporting_policy = Some(false);
2911 config.capabilities.scorecard = Some(true);
2912 config.capabilities.code_scanning = Some("semgrep".to_owned());
2913 config.capabilities.workflow_guards = Some(true);
2914 config.security.advisories =
2918 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
2919 config.security.contact = Some("security team, room 3 \"the vault\"".into());
2920 config.security.response = Some("14 business days".into());
2921 config.security.scope =
2924 Some("Wraps the \"claude\" CLI; report it upstream.\nRK_CONFIG_SECURITY_SCOPE stays literal.".into());
2925 config.setup.required_check = "build / test".into();
2926 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
2927 config.setup.release_lines = true;
2928 config.setup.excluded_steps = [
2929 (
2930 "package-check".to_owned(),
2931 "nothing is published".to_owned(),
2932 ),
2933 (
2934 "protect-trunk".to_owned(),
2935 "this project merges \"locally\"".to_owned(),
2936 ),
2937 ]
2938 .into_iter()
2939 .collect();
2940 config.setup.bot.app_id = "123".into();
2941 config.protection.trunk_ruleset = Some("primary".into());
2942 config.protection.safety_ruleset = Some("guardrail".into());
2943 config.protection.tag_ruleset = "versions".into();
2944 config.protection.lines_ruleset = "maintenance".into();
2945 config.protection.title_check = "intent".into();
2946 config.protection.tag_pattern = "refs/tags/*".into();
2947 config
2948 .protection
2949 .owned_trunk_rules
2950 .push("required_signatures".into());
2951 config.protection.required_approving_review_count = 2;
2952 config.protection.dismiss_stale_reviews_on_push = true;
2953 config.protection.require_code_owner_review = true;
2954 config.protection.require_last_push_approval = true;
2955 config.protection.gitlab.squash_commit_template =
2956 "%{title}\n\nContext: %{description}".into();
2957 config.protection.gitlab.merge_access_level = 40;
2958 let mut release_less = resolved_defaults();
2961 release_less.project.repo = String::new();
2962 release_less.profile.technologies = Some(Vec::new());
2963 release_less.profile.forge = Some(String::new());
2964 release_less.profile.release = super::Release {
2965 mode: Some(ReleaseMode::None),
2966 driver: None,
2967 style: None,
2968 line_prefix: None,
2969 registry: None,
2970 build: None,
2971 };
2972 release_less.capabilities.reporting_policy = Some(false);
2973 release_less.security.contact = None;
2976 release_less.security.response = None;
2977 release_less.security.scope = None;
2978 for expected in [resolved_defaults(), config, release_less] {
2979 write(dir.path(), &expected).expect("the template renders");
2980 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
2981 let text =
2982 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2983 assert!(text.contains("# P: every technology"));
2984 assert!(text.contains("# F: invariant"));
2985 }
2986 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2987 assert!(
2988 !text.contains("style ="),
2989 "a none release writes no style: {text}"
2990 );
2991 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
2992 }
2993
2994 #[test]
2995 fn a_config_with_an_unknown_key_refuses_by_name() {
2996 for (table, typo, nearest) in [
2997 ("", "schemax_version", "schema_version"),
2998 ("git", "trunkx", "trunk"),
2999 ("profile.release", "stile", "style"),
3000 ("capabilities", "nix_packagingg", "nix_packaging"),
3001 ("security", "contactx", "contact"),
3002 ("setup", "required_checkx", "required_check"),
3003 ("setup.bot", "app_i", "app_id"),
3004 ("protection", "trunk_rulesett", "trunk_ruleset"),
3005 (
3006 "protection.github",
3007 "squash_body_sourcex",
3008 "squash_body_source",
3009 ),
3010 ("protection.gitlab", "squash_optionx", "squash_option"),
3011 ] {
3012 let header = if table.is_empty() {
3013 String::new()
3014 } else {
3015 format!("[{table}]\n")
3016 };
3017 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
3018 let error = parse(&text).expect_err("unknown keys refuse").to_string();
3019 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
3020 assert!(error.contains(expected), "{error}");
3021 }
3022 }
3023 }
3024
3025 #[test]
3029 fn an_exclusion_names_a_real_step_and_states_why() {
3030 for (text, expected) in [
3031 (
3032 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
3033 vec!["protect-trunkk", "nearest known step: protect-trunk"],
3034 ),
3035 (
3036 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
3037 vec!["protect-trunk", "no reason"],
3038 ),
3039 ] {
3040 let error = parse(&format!("schema_version = 2\n{text}"))
3041 .expect_err("the exclusion refuses")
3042 .to_string();
3043 for want in expected {
3044 assert!(error.contains(want), "{error}");
3045 }
3046 }
3047 let held = parse(
3048 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
3049 )
3050 .expect("a named step with a reason parses");
3051 assert_eq!(
3052 held.setup
3053 .excluded_steps
3054 .get("protect-trunk")
3055 .map(String::as_str),
3056 Some("we merge locally")
3057 );
3058 }
3059
3060 #[test]
3064 fn an_exclusion_does_not_lift_a_floor() {
3065 let error = parse(
3066 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
3067 )
3068 .expect_err("the floor binds an excluded step's keys too")
3069 .to_string();
3070 assert!(
3071 error.contains("protection.allowed_merge_methods"),
3072 "{error}"
3073 );
3074 }
3075
3076 #[test]
3077 fn a_config_at_an_unknown_schema_refuses() {
3078 for text in ["schema_version = 999", "schema_version = '2'", ""] {
3079 let error = parse(text)
3080 .expect_err("a schema must be declared and known")
3081 .to_string();
3082 assert!(
3083 error.contains(CONFIG_PATH) && error.contains("schema_version"),
3084 "{error}"
3085 );
3086 }
3087 }
3088
3089 #[test]
3090 fn an_unparsable_config_refuses_naming_the_position() {
3091 let error = parse("schema_version = 2\n[project\n")
3092 .expect_err("bad TOML refuses")
3093 .to_string();
3094 for expected in [CONFIG_PATH, "line 2", "column"] {
3095 assert!(error.contains(expected), "{error}");
3096 }
3097 }
3098
3099 #[test]
3100 fn an_absent_config_reads_as_none() {
3101 let dir = tempfile::tempdir().expect("a target exists");
3102 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
3103 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
3104 }
3105
3106 #[test]
3107 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
3108 let dir = tempfile::tempdir().expect("a target exists");
3109 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
3110 std::fs::write(
3111 dir.path().join(CONFIG_PATH),
3112 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
3113 )
3114 .expect("a config exists");
3115 let error =
3116 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
3117 assert_eq!(error.exit_code(), 73);
3118 assert!(
3119 error
3120 .to_string()
3121 .contains("protection.strict_required_status_checks")
3122 );
3123 }
3124
3125 #[test]
3128 fn an_equal_value_keeps_its_spelling() {
3129 let original = "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'tr\\u0075nk' # ours\n";
3130 let original = original.replace("'tr\\u0075nk'", "\"tr\\u0075nk\"");
3131 let text = super::rewrite_text(&original, "profile.release.style", Some("trunk".into()))
3132 .expect("the style rewrites");
3133 assert_eq!(text, original, "an equal value is rewritten");
3134 let quoted =
3135 "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk' # ours\n";
3136 let text = super::rewrite_text(quoted, "profile.release.style", Some("trunk".into()))
3137 .expect("the style rewrites");
3138 assert_eq!(
3139 text, quoted,
3140 "a literal string equal to the value is rewritten"
3141 );
3142 }
3143
3144 #[test]
3145 fn rewrite_key_preserves_comments() {
3146 let dir = tempfile::tempdir().expect("a target exists");
3147 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
3148 let original = "# Project answers\nschema_version = 2\n\n[security] # first table stays first\ncontact = 'team' # keep me\n\n[profile.release]\n# Our release choice\nmode = 'automatic'\nstyle = 'trunk' # keep this reason\n\n[git]\ncheckout_mode = 'main-worktree'\n";
3149 let path = dir.path().join(CONFIG_PATH);
3150 std::fs::write(&path, original).expect("a config exists");
3151 rewrite_key(dir.path(), "profile.release.style", "lines".into())
3152 .expect("the style writes back");
3153 let text = std::fs::read_to_string(&path).expect("the text reads");
3154 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
3155 assert_eq!(
3156 load(dir.path())
3157 .expect("the config reads")
3158 .expect("present")
3159 .profile
3160 .release
3161 .style,
3162 Some(Style::Lines)
3163 );
3164 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
3165 .expect("an omitted table can be added");
3166 assert_eq!(
3167 load(dir.path())
3168 .expect("reads")
3169 .expect("present")
3170 .project
3171 .repo,
3172 "acme/widget"
3173 );
3174 rewrite_key(
3175 dir.path(),
3176 "security.contact",
3177 "security@acme.example".into(),
3178 )
3179 .expect("the contact is a landing parameter");
3180 rewrite_key(dir.path(), "security.response", "14 days".into())
3181 .expect("the response is a landing parameter");
3182 let held = load(dir.path()).expect("reads").expect("present");
3183 assert_eq!(
3184 held.security.contact.as_deref(),
3185 Some("security@acme.example")
3186 );
3187 assert_eq!(held.security.response.as_deref(), Some("14 days"));
3188 let text = std::fs::read_to_string(&path).expect("the text reads");
3189 assert!(text.contains("# keep me"), "the comment survives: {text}");
3190 let before = std::fs::read(&path).expect("the bytes read");
3191 for (key, value) in [
3192 ("security.advisories", "acme/private"),
3193 ("security.response", "90d"),
3194 ("profile.release.style", "unknown"),
3195 ] {
3196 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
3197 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
3198 }
3199 }
3200
3201 #[test]
3204 fn the_security_answers_are_held_to_their_grammar() {
3205 for value in ["team@acme.example", " https://acme.example/report ", ""] {
3206 super::canonical_contact(value).expect("a control-free line is a contact");
3207 }
3208 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
3209 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
3210 assert!(refusal.contains("security.contact"), "{refusal}");
3211 }
3212 assert_eq!(
3213 super::canonical_contact(" team@acme.example "),
3214 Ok("team@acme.example".to_owned()),
3215 "surrounding whitespace is trimmed"
3216 );
3217 for value in [
3218 "best-effort",
3219 "1 day",
3220 "2 days",
3221 "14 days",
3222 "1 business day",
3223 "14 business days",
3224 ] {
3225 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
3226 }
3227 assert_eq!(
3228 super::canonical_response(""),
3229 Ok(super::RESPONSE_DEFAULT.to_owned()),
3230 "an empty answer reads as the compiled default"
3231 );
3232 for value in [
3233 "0 days",
3234 "1 days",
3235 "2 day",
3236 "+2 days",
3237 "02 days",
3238 "4294967296 days",
3239 "90d",
3240 "two days",
3241 "we answer quickly",
3242 "2 weeks",
3243 ] {
3244 let refusal =
3245 super::canonical_response(value).expect_err("an unstateable window refuses");
3246 assert!(refusal.contains("security.response"), "{value}: {refusal}");
3247 assert!(refusal.contains("business days"), "{value}: {refusal}");
3248 }
3249 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
3250 .expect_err("the reader refuses it too")
3251 .to_string();
3252 assert!(refusal.contains("security.response"), "{refusal}");
3253 }
3254
3255 #[test]
3258 fn the_security_scope_is_one_paragraph() {
3259 for value in [
3260 "",
3261 "This wraps the claude CLI. Report a flaw in it upstream.",
3262 "One line,\nand a second line of the same paragraph.",
3263 "A #hashtag mid-line and a <comment> stay prose.",
3264 "A dash - and an = sign mid-line,\nand a line ending in --- stay prose.",
3265 ] {
3266 assert_eq!(super::canonical_scope(value), Ok(value.to_owned()));
3267 }
3268 assert_eq!(
3269 super::canonical_scope(" wrapped \n"),
3270 Ok("wrapped".to_owned()),
3271 "surrounding whitespace is trimmed"
3272 );
3273 for (value, why) in [
3274 ("one\n\ntwo", "blank line"),
3275 ("one\n \ntwo", "blank line"),
3276 ("one\r\ntwo", "control character"),
3277 ("one\ttwo", "control character"),
3278 ("# Out of scope", "heading"),
3279 ("intro\n ## Out of scope", "heading"),
3280 ("Out of scope\n===", "underlines a Markdown heading"),
3281 ("Out of scope\n --- ", "underlines a Markdown heading"),
3282 ("see <!-- here", "comment delimiter"),
3283 ("here --> there", "comment delimiter"),
3284 ] {
3285 let refusal =
3286 super::canonical_scope(value).expect_err("a policy-breaking scope refuses");
3287 assert!(refusal.contains("security.scope"), "{value:?}: {refusal}");
3288 assert!(refusal.contains(why), "{value:?}: {refusal}");
3289 }
3290 }
3291
3292 #[test]
3296 fn a_migration_key_that_is_no_identifier_refuses() {
3297 for id in ["20261009T140205Z", "20261009T140205Z-2"] {
3298 let config = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
3299 .expect("an identifier parses");
3300 assert_eq!(config.migration.as_deref(), Some(id));
3301 }
3302 for id in [
3303 "../../elsewhere",
3304 "20261009T140205Z-1",
3305 "",
3306 "20261009T140205\u{e9}",
3307 "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
3308 ] {
3309 let refusal = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
3310 .expect_err("a value that is no identifier refuses")
3311 .to_string();
3312 assert!(
3313 refusal.contains("no migration identifier"),
3314 "{id}: {refusal}"
3315 );
3316 }
3317 }
3318
3319 #[test]
3324 fn a_pruned_header_leaves_no_comment_behind() {
3325 let cases = [
3326 (
3327 "a middle table, comment above",
3328 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
3329 ),
3330 (
3331 "a middle table, comment inline",
3332 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
3333 ),
3334 (
3335 "the last table, comment above",
3336 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
3337 ),
3338 (
3339 "the last table, comment inline",
3340 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
3341 ),
3342 ];
3343 for (case, text) in cases {
3344 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
3345 assert!(!next.contains("repo ="), "{case}: {next}");
3346 assert!(
3347 !next.contains("[project]"),
3348 "{case}: a table every one of whose keys dropped goes with them: {next}"
3349 );
3350 assert!(
3351 next.contains("# the operator's note"),
3352 "{case}: the comment the header carried survives: {next}"
3353 );
3354 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
3355 }
3356 }
3357
3358 #[test]
3364 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
3365 let text = concat!(
3366 "schema_version = 2\n\n[project]\n",
3367 "# the operator's note\n",
3368 "repo = \"acme/widget\" # P: project path on the forge\n\n",
3369 "[git]\ntrunk = \"main\"\n"
3370 );
3371 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
3372 assert!(!next.contains("repo ="), "{next}");
3373 assert!(!next.contains("[project]"), "{next}");
3374 assert!(
3375 next.contains("# the operator's note"),
3376 "authored text survives: {next}"
3377 );
3378 assert!(
3379 !next.contains("# P:"),
3380 "the template's comment describes a key that is gone: {next}"
3381 );
3382 parse(&next).expect("the result parses");
3383
3384 let text = concat!(
3386 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
3387 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
3388 "# why this project names its own prefix\n",
3389 "line_prefix = \"stable/\"\n"
3390 );
3391 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
3392 .expect("the key removes");
3393 assert!(!next.contains("line_prefix ="), "{next}");
3394 assert!(next.contains("[profile.release]"), "{next}");
3395 assert!(
3396 next.contains("# why this project names its own prefix"),
3397 "authored text survives: {next}"
3398 );
3399 }
3400}