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,
418 pub required_workflow: String,
422 pub retired_branches: Vec<String>,
424 pub release_lines: bool,
426 pub excluded_steps: BTreeMap<String, String>,
431 pub bot: Bot,
433}
434
435impl Default for Setup {
436 fn default() -> Self {
437 Self {
438 required_check: String::new(),
439 required_workflow: String::new(),
440 retired_branches: vec!["main".into(), "develop".into()],
441 release_lines: false,
442 excluded_steps: BTreeMap::new(),
443 bot: Bot::default(),
444 }
445 }
446}
447
448#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
455#[serde(deny_unknown_fields, default)]
456pub struct Bot {
457 pub app_id: String,
459 pub name: String,
462 #[serde(default, skip_serializing)]
467 pub installation_id: Option<i64>,
468}
469
470#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
472#[serde(deny_unknown_fields, default)]
473#[allow(
474 clippy::struct_excessive_bools,
475 reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
476)]
477pub struct Protection {
478 pub trunk_ruleset: Option<String>,
482 pub safety_ruleset: Option<String>,
487 pub tag_ruleset: String,
489 pub lines_ruleset: String,
491 pub title_check: String,
493 pub tag_pattern: String,
495 pub bypass_actors: Vec<String>,
498 pub allowed_merge_methods: Vec<String>,
501 pub strict_required_status_checks: bool,
503 pub owned_trunk_rules: Vec<String>,
507 pub required_approving_review_count: i64,
509 pub dismiss_stale_reviews_on_push: bool,
511 pub require_code_owner_review: bool,
513 pub require_last_push_approval: bool,
515 pub github: Github,
517 pub gitlab: Gitlab,
519}
520
521impl Default for Protection {
522 fn default() -> Self {
523 Self {
524 trunk_ruleset: None,
525 safety_ruleset: None,
526 tag_ruleset: "release-tags".into(),
527 lines_ruleset: "release-lines".into(),
528 title_check: "pr-title".into(),
529 tag_pattern: "refs/tags/v*".into(),
530 bypass_actors: Vec::new(),
531 allowed_merge_methods: vec!["squash".into()],
532 strict_required_status_checks: true,
533 owned_trunk_rules: vec![
534 "deletion".into(),
535 "non_fast_forward".into(),
536 "pull_request".into(),
537 "required_status_checks".into(),
538 ],
539 required_approving_review_count: 0,
540 dismiss_stale_reviews_on_push: false,
541 require_code_owner_review: false,
542 require_last_push_approval: false,
543 github: Github::default(),
544 gitlab: Gitlab::default(),
545 }
546 }
547}
548
549impl Protection {
550 #[must_use]
553 pub fn trunk_ruleset(&self, trunk: &str) -> String {
554 self.trunk_ruleset
555 .clone()
556 .unwrap_or_else(|| format!("{trunk}-protection"))
557 }
558
559 #[must_use]
566 pub fn safety_ruleset(&self, trunk: &str) -> String {
567 self.safety_ruleset
568 .clone()
569 .unwrap_or_else(|| format!("{trunk}-safety"))
570 }
571}
572
573pub const SAFETY_RULES: [&str; 3] = ["deletion", "non_fast_forward", LINEAR_HISTORY_RULE];
583
584pub const COMMIT_GATE_CHECK: &str = "pr-commits";
588
589pub const LINEAR_HISTORY_RULE: &str = "required_linear_history";
593
594pub const REQUEST_RULES: [&str; 2] = ["pull_request", "required_status_checks"];
597
598#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
600#[serde(deny_unknown_fields, default)]
601pub struct Github {
602 pub squash_title_source: String,
605 pub squash_body_source: String,
607}
608
609impl Default for Github {
610 fn default() -> Self {
611 Self {
612 squash_title_source: "PR_TITLE".into(),
613 squash_body_source: "PR_BODY".into(),
614 }
615 }
616}
617
618#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
620#[serde(deny_unknown_fields, default)]
621pub struct Gitlab {
622 pub merge_method: String,
624 pub squash_option: String,
628 pub squash_commit_template: String,
630 pub push_access_level: i64,
635 pub merge_access_level: i64,
637}
638
639impl Default for Gitlab {
640 fn default() -> Self {
641 Self {
642 merge_method: "ff".into(),
643 squash_option: "always".into(),
644 squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
645 .trim_end_matches('\n')
646 .to_owned(),
647 push_access_level: 0,
648 merge_access_level: 40,
649 }
650 }
651}
652
653pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
658 let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
659 Ok(text) => text,
660 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
661 Err(error) => return Err(error.into()),
662 };
663 parse(&text).map(Some)
664}
665
666fn current_text(text: &str) -> Result<String, RkError> {
669 let raw: toml::Value =
670 toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
671 match raw.get("schema_version").and_then(toml::Value::as_integer) {
672 Some(1) => migrate::to_schema_2(text),
673 _ => Ok(text.to_owned()),
674 }
675}
676
677fn release_vocabulary(raw: &toml::Value) -> Result<(), RkError> {
683 let release = raw
684 .get("profile")
685 .and_then(|profile| profile.get("release"));
686 if let Some(mode) = release.and_then(|release| release.get("mode")) {
687 let named = mode.as_str().ok_or_else(|| {
688 invalid("profile.release.mode must be a string: automatic, external, or none")
689 })?;
690 crate::profile::ReleaseMode::parse(named)
691 .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
692 }
693 match release.and_then(|release| release.get("registry")) {
694 Some(registry) if !matches!(registry.as_str(), Some("crates-io" | "pypi" | "none")) => {
695 Err(invalid(format!(
696 "profile.release.registry is {registry}; it takes {}, where the release publishes",
697 crate::profile::RegistryAnswer::SHAPE
698 )))
699 }
700 _ => Ok(()),
701 }
702}
703
704pub fn build_command_shape(raw: &str) -> Result<(), String> {
711 if raw.trim().is_empty() {
712 return Err("profile.release.build.command is empty; it names the command that produces dist/*.tar.gz and its .sha256, such as make dist".to_owned());
713 }
714 if raw.chars().any(char::is_control) {
715 return Err(format!(
716 "profile.release.build.command is one line with no control character; found {raw:?}"
717 ));
718 }
719 Ok(())
720}
721
722pub fn build_packages_shape(raw: &str) -> Result<(), String> {
728 let shaped = raw.split(' ').filter(|word| !word.is_empty()).all(|word| {
729 word.chars()
730 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '_' | ':' | '-'))
731 });
732 if shaped {
733 Ok(())
734 } else {
735 Err(format!(
736 "profile.release.build.packages is package names separated by spaces, each of letters, digits, '.', '+', '_', ':', or '-'; found {raw:?}"
737 ))
738 }
739}
740
741fn build_fit(release: &Release) -> Result<(), RkError> {
745 let Some(build) = &release.build else {
746 return Ok(());
747 };
748 if let Some(command) = &build.command {
749 build_command_shape(command).map_err(invalid)?;
750 }
751 if let Some(packages) = &build.packages {
752 build_packages_shape(packages).map_err(invalid)?;
753 }
754 if release.driver.is_some()
755 || matches!(
756 release.mode,
757 Some(ReleaseMode::External | ReleaseMode::None)
758 )
759 {
760 crate::profile::catalog::build_answer_fits(
761 release.mode.unwrap_or(ReleaseMode::Automatic),
762 release.driver.as_deref(),
763 )
764 .map_err(invalid)?;
765 }
766 Ok(())
767}
768
769fn registry_fit(release: &Release) -> Result<(), RkError> {
772 if let Some(answer) = release.registry
773 && (release.driver.is_some()
774 || matches!(
775 release.mode,
776 Some(ReleaseMode::External | ReleaseMode::None)
777 ))
778 {
779 crate::profile::catalog::registry_answer_fits(
780 release.mode.unwrap_or(ReleaseMode::Automatic),
781 release.driver.as_deref(),
782 answer,
783 )
784 .map_err(invalid)?;
785 }
786 Ok(())
787}
788
789fn parse(text: &str) -> Result<Config, RkError> {
790 let text = current_text(text)?;
791 let raw: toml::Value =
792 toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
793 if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
794 return Err(invalid(format!(
795 "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
796 )));
797 }
798 release_vocabulary(&raw)?;
799 let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
800 let mut message = error.to_string();
801 if let Some(rest) = error.message().strip_prefix("unknown field `") {
802 let names: Vec<_> = rest.split('`').collect();
803 if let Some(unknown) = names.first()
804 && let Some(nearest) = names
805 .iter()
806 .skip(2)
807 .step_by(2)
808 .min_by_key(|name| distance(unknown, name))
809 {
810 let _ = write!(message, "; nearest known key: {nearest}");
811 }
812 }
813 invalid(message)
814 })?;
815 if let Some(technologies) = &config.profile.technologies {
816 crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
817 }
818 if let Some(forge) = &config.profile.forge
819 && !forge.is_empty()
820 {
821 crate::profile::canonical_category(forge)
822 .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
823 }
824 if let Some(driver) = &config.profile.release.driver {
825 crate::profile::canonical_category(driver)
826 .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
827 }
828 if matches!(
829 config.profile.release.mode,
830 Some(ReleaseMode::External | ReleaseMode::None)
831 ) {
832 let mode = config
833 .profile
834 .release
835 .mode
836 .map_or("none", ReleaseMode::as_str);
837 for (key, present) in [
838 (
839 "profile.release.driver",
840 config.profile.release.driver.is_some(),
841 ),
842 (
843 "profile.release.style",
844 config.profile.release.style.is_some(),
845 ),
846 (
847 "profile.release.line_prefix",
848 config.profile.release.line_prefix.is_some(),
849 ),
850 ] {
851 if present {
852 return Err(invalid(format!(
853 "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
854 )));
855 }
856 }
857 }
858 registry_fit(&config.profile.release)?;
859 build_fit(&config.profile.release)?;
860 if let Some(contact) = &config.security.contact {
861 canonical_contact(contact).map_err(invalid)?;
862 }
863 if let Some(response) = &config.security.response {
864 canonical_response(response).map_err(invalid)?;
865 }
866 if let Some(scope) = &config.security.scope {
867 canonical_scope(scope).map_err(invalid)?;
868 }
869 bot_name_shape(&config.setup.bot.name).map_err(invalid)?;
870 exclusions(&config.setup.excluded_steps)?;
871 accepted_drift(&config.landing.accepted_drift)?;
872 if let Some(acquisition) = &config.acquisition
873 && !is_release_version(&acquisition.minimum)
874 {
875 return Err(invalid(format!(
876 "acquisition.minimum is {}, which is no release version; write one such as {}",
877 acquisition.minimum,
878 env!("CARGO_PKG_VERSION")
879 )));
880 }
881 if let Some(answer) = &config.dev_environment {
882 answer.check().map_err(invalid)?;
883 }
884 migration_key(&config)?;
885 floors::check(&config)?;
886 Ok(config)
887}
888
889fn migration_key(config: &Config) -> Result<(), RkError> {
892 match &config.migration {
893 Some(id) if !crate::migrate::is_id(id) => Err(invalid(format!(
894 "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)"
895 ))),
896 _ => Ok(()),
897 }
898}
899
900fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
907 for (name, reason) in excluded {
908 if crate::setup::steps::spec(name).is_none() {
909 let nearest = crate::setup::steps::STEPS
910 .iter()
911 .min_by_key(|step| distance(name, step.name))
912 .map_or("", |step| step.name);
913 return Err(invalid(format!(
914 "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
915 )));
916 }
917 if reason.trim().is_empty() {
918 return Err(invalid(format!(
919 "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
920 )));
921 }
922 }
923 Ok(())
924}
925
926fn accepted_drift(entries: &BTreeMap<String, AcceptedDrift>) -> Result<(), RkError> {
932 for (destination, entry) in entries {
933 if entry.reason.trim().is_empty() {
934 return Err(invalid(format!(
935 "landing.accepted_drift names {destination} with no reason; an accepted deviation is reported with why the project must make it"
936 )));
937 }
938 let hex = entry.sha256.len() == 64
939 && entry
940 .sha256
941 .chars()
942 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c));
943 if !hex {
944 return Err(invalid(format!(
945 "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",
946 entry.sha256
947 )));
948 }
949 }
950 Ok(())
951}
952
953pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
954 RkError::refusal(
955 Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
956 .action(format!("edit {CONFIG_PATH} and retry"))
957 .target_state("nothing was written"),
958 )
959}
960
961fn distance(left: &str, right: &str) -> usize {
962 let mut row: Vec<_> = (0..=right.chars().count()).collect();
963 for (i, a) in left.chars().enumerate() {
964 let mut previous = row[0];
965 row[0] = i + 1;
966 for (j, b) in right.chars().enumerate() {
967 let old = row[j + 1];
968 row[j + 1] = (previous + usize::from(a != b))
969 .min(row[j] + 1)
970 .min(old + 1);
971 previous = old;
972 }
973 }
974 row.last().copied().unwrap_or(0)
975}
976
977pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
983 let bytes = render(config)?;
984 parse(&String::from_utf8_lossy(&bytes))?;
985 write_checked(target, &bytes)
986}
987
988fn write_checked(target: &Path, bytes: &[u8]) -> Result<(), RkError> {
991 check_writable(target)?;
992 crate::atomic::write(&target.join(CONFIG_PATH), bytes)?;
993 Ok(())
994}
995
996fn array(values: &[String]) -> toml_edit::Value {
997 toml_edit::Value::Array(values.iter().collect())
998}
999
1000fn accepted_inline(entries: &BTreeMap<String, AcceptedDrift>) -> toml_edit::Value {
1006 let mut table = toml_edit::InlineTable::new();
1007 for (destination, entry) in entries {
1008 let mut row = toml_edit::InlineTable::new();
1009 row.insert("reason", entry.reason.clone().into());
1010 row.insert("sha256", entry.sha256.clone().into());
1011 table.insert(destination, toml_edit::Value::InlineTable(row));
1012 }
1013 toml_edit::Value::InlineTable(table)
1014}
1015
1016fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
1017 let mut table = toml_edit::InlineTable::new();
1018 for (key, value) in values {
1019 table.insert(key, value.clone().into());
1020 }
1021 toml_edit::Value::InlineTable(table)
1022}
1023
1024#[allow(
1029 clippy::too_many_lines,
1030 reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
1031)]
1032fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
1033 let trunk = config
1037 .git
1038 .trunk
1039 .clone()
1040 .ok_or_else(|| invalid("git.trunk is unresolved"))?;
1041 let technologies = config
1042 .profile
1043 .technologies
1044 .clone()
1045 .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
1046 let forge = config
1047 .profile
1048 .forge
1049 .clone()
1050 .ok_or_else(|| invalid("profile.forge is unresolved"))?;
1051 let mode = config
1052 .profile
1053 .release
1054 .mode
1055 .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
1056 let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
1057 (
1058 "RK_CONFIG_SCHEMA_VERSION",
1059 Some(config.schema_version.into()),
1060 ),
1061 (
1062 "RK_CONFIG_PROJECT_REPO",
1063 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1064 ),
1065 ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
1066 ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
1067 ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
1068 (
1069 "RK_CONFIG_PROFILE_RELEASE_DRIVER",
1070 config
1071 .profile
1072 .release
1073 .driver
1074 .clone()
1075 .map(toml_edit::Value::from),
1076 ),
1077 (
1078 "RK_CONFIG_PROFILE_RELEASE_STYLE",
1079 config
1080 .profile
1081 .release
1082 .style
1083 .map(|style| style.as_str().into()),
1084 ),
1085 (
1086 "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
1087 config
1088 .profile
1089 .release
1090 .line_prefix
1091 .clone()
1092 .map(toml_edit::Value::from),
1093 ),
1094 (
1095 "RK_CONFIG_PROFILE_RELEASE_REGISTRY",
1096 config
1097 .profile
1098 .release
1099 .registry
1100 .map(|answer| answer.as_str().into()),
1101 ),
1102 (
1103 "RK_CONFIG_PROFILE_RELEASE_BUILD_COMMAND",
1104 config
1105 .profile
1106 .release
1107 .build
1108 .as_ref()
1109 .and_then(|build| build.command.clone())
1110 .map(toml_edit::Value::from),
1111 ),
1112 (
1113 "RK_CONFIG_PROFILE_RELEASE_BUILD_PACKAGES",
1114 config
1115 .profile
1116 .release
1117 .build
1118 .as_ref()
1119 .and_then(|build| build.packages.clone())
1120 .map(toml_edit::Value::from),
1121 ),
1122 ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
1123 (
1124 "RK_CONFIG_GIT_CHECKOUT_MODE",
1125 Some(
1126 config
1127 .git
1128 .checkout_mode
1129 .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
1130 .as_str()
1131 .into(),
1132 ),
1133 ),
1134 (
1135 "RK_CONFIG_GIT_INTEGRATION",
1136 Some(
1137 config
1138 .git
1139 .integration
1140 .ok_or_else(|| invalid("git.integration is unresolved"))?
1141 .as_str()
1142 .into(),
1143 ),
1144 ),
1145 (
1146 "RK_CONFIG_GIT_MERGE_FORM",
1147 Some(
1148 config
1149 .git
1150 .merge_form
1151 .ok_or_else(|| invalid("git.merge_form is unresolved"))?
1152 .as_str()
1153 .into(),
1154 ),
1155 ),
1156 (
1157 "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
1158 Some(
1159 config
1160 .capabilities
1161 .nix_packaging
1162 .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
1163 .into(),
1164 ),
1165 ),
1166 (
1167 "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
1168 Some(
1169 config
1170 .capabilities
1171 .reporting_policy
1172 .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
1173 .into(),
1174 ),
1175 ),
1176 (
1177 "RK_CONFIG_CAPABILITIES_SCORECARD",
1178 Some(
1179 config
1180 .capabilities
1181 .scorecard
1182 .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
1183 .into(),
1184 ),
1185 ),
1186 (
1187 "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
1188 Some(
1189 config
1190 .capabilities
1191 .code_scanning
1192 .clone()
1193 .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
1194 .into(),
1195 ),
1196 ),
1197 (
1198 "RK_CONFIG_CAPABILITIES_WORKFLOW_GUARDS",
1199 Some(
1200 config
1201 .capabilities
1202 .workflow_guards
1203 .ok_or_else(|| invalid("capabilities.workflow_guards is unresolved"))?
1204 .into(),
1205 ),
1206 ),
1207 (
1208 "RK_CONFIG_SECURITY_ADVISORIES",
1209 Some(config.security.advisories.clone().into()),
1210 ),
1211 (
1212 "RK_CONFIG_SECURITY_CONTACT",
1213 Some(config.security.contact.clone().unwrap_or_default().into()),
1214 ),
1215 (
1216 "RK_CONFIG_SECURITY_RESPONSE",
1217 Some(
1218 config
1219 .security
1220 .response
1221 .clone()
1222 .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
1223 .into(),
1224 ),
1225 ),
1226 (
1227 "RK_CONFIG_SECURITY_SCOPE",
1228 Some(config.security.scope.clone().unwrap_or_default().into()),
1229 ),
1230 (
1231 "RK_CONFIG_SETUP_REQUIRED_CHECK",
1232 Some(config.setup.required_check.clone().into()),
1233 ),
1234 (
1235 "RK_CONFIG_SETUP_REQUIRED_WORKFLOW",
1236 Some(config.setup.required_workflow.clone().into()),
1237 ),
1238 (
1239 "RK_CONFIG_SETUP_RETIRED_BRANCHES",
1240 Some(array(&config.setup.retired_branches)),
1241 ),
1242 (
1243 "RK_CONFIG_SETUP_RELEASE_LINES",
1244 Some(config.setup.release_lines.into()),
1245 ),
1246 (
1247 "RK_CONFIG_SETUP_EXCLUDED_STEPS",
1248 Some(inline(&config.setup.excluded_steps)),
1249 ),
1250 (
1251 "RK_CONFIG_LANDING_ACCEPTED_DRIFT",
1252 Some(accepted_inline(&config.landing.accepted_drift)),
1253 ),
1254 (
1255 "RK_CONFIG_SETUP_BOT_APP_ID",
1256 Some(config.setup.bot.app_id.clone().into()),
1257 ),
1258 (
1259 "RK_CONFIG_SETUP_BOT_NAME",
1260 Some(config.setup.bot.name.clone().into()),
1261 ),
1262 (
1263 "RK_CONFIG_ACQUISITION_SOURCE",
1264 config
1265 .acquisition
1266 .as_ref()
1267 .map(|answer| answer.source.as_str().into()),
1268 ),
1269 (
1270 "RK_CONFIG_ACQUISITION_MINIMUM",
1271 config
1272 .acquisition
1273 .as_ref()
1274 .map(|answer| answer.minimum.clone().into()),
1275 ),
1276 ];
1277 fields.extend(
1278 protection_fields(&config.protection, trunk.as_str())
1279 .into_iter()
1280 .map(|(token, value)| (token, Some(value))),
1281 );
1282 if mode == crate::profile::ReleaseMode::None {
1288 let reporting = config.capabilities.reporting_policy.unwrap_or(false);
1289 for (token, value) in &mut fields {
1290 if RELEASE_ONLY_TOKENS.contains(token)
1291 || (!reporting && SECURITY_TOKENS.contains(token))
1292 {
1293 *value = None;
1294 }
1295 }
1296 }
1297 Ok(fields)
1298}
1299
1300const RELEASE_ONLY_TOKENS: [&str; 5] = [
1306 "RK_CONFIG_PROTECTION_TAG_RULESET",
1307 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1308 "RK_CONFIG_PROTECTION_LINES_RULESET",
1309 "RK_CONFIG_SETUP_BOT_APP_ID",
1310 "RK_CONFIG_SETUP_BOT_NAME",
1311];
1312
1313const SECURITY_TOKENS: [&str; 4] = [
1316 "RK_CONFIG_SECURITY_ADVISORIES",
1317 "RK_CONFIG_SECURITY_CONTACT",
1318 "RK_CONFIG_SECURITY_RESPONSE",
1319 "RK_CONFIG_SECURITY_SCOPE",
1320];
1321
1322fn render(config: &Config) -> Result<Vec<u8>, RkError> {
1323 let fields = fields(config)?;
1324 let template = crate::embedded::BLOCKS
1325 .get_file("target-config.toml.in")
1326 .and_then(include_dir::File::contents_utf8)
1327 .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
1328 let lines: Vec<&str> = template.split_inclusive('\n').collect();
1334 let dropped: Vec<bool> = lines
1335 .iter()
1336 .map(|line| {
1337 matches!(
1338 fields.iter().find(|(token, _)| line.contains(token)),
1339 Some((_, None))
1340 )
1341 })
1342 .collect();
1343 let mut bytes = Vec::new();
1344 let mut at = 0;
1345 while at < lines.len() {
1346 let line = lines[at];
1347 if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
1348 let mut end = at + 1;
1349 let mut keeps = false;
1350 while end < lines.len()
1351 && !(lines[end].trim_start().starts_with('[')
1352 && lines[end].trim_end().ends_with(']'))
1353 {
1354 keeps |=
1355 fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
1356 end += 1;
1357 }
1358 if !keeps {
1359 if end == lines.len() {
1364 while bytes.last() == Some(&b'\n')
1365 && bytes.len() >= 2
1366 && bytes[bytes.len() - 2] == b'\n'
1367 {
1368 bytes.pop();
1369 }
1370 }
1371 at = end;
1372 continue;
1373 }
1374 }
1375 if !dropped[at] {
1376 match fields.iter().find(|(token, _)| line.contains(token)) {
1377 Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
1378 line.as_bytes(),
1379 token.as_bytes(),
1380 value.to_string().as_bytes(),
1381 )),
1382 Some((_, None)) => {}
1383 None => bytes.extend_from_slice(line.as_bytes()),
1384 }
1385 }
1386 at += 1;
1387 }
1388 Ok(bytes)
1389}
1390
1391fn protection_fields(
1392 protection: &Protection,
1393 trunk: &str,
1394) -> Vec<(&'static str, toml_edit::Value)> {
1395 vec![
1396 (
1397 "RK_CONFIG_PROTECTION_TRUNK_RULESET",
1398 protection.trunk_ruleset(trunk).into(),
1399 ),
1400 (
1401 "RK_CONFIG_PROTECTION_SAFETY_RULESET",
1402 protection.safety_ruleset(trunk).into(),
1403 ),
1404 (
1405 "RK_CONFIG_PROTECTION_TAG_RULESET",
1406 protection.tag_ruleset.clone().into(),
1407 ),
1408 (
1409 "RK_CONFIG_PROTECTION_LINES_RULESET",
1410 protection.lines_ruleset.clone().into(),
1411 ),
1412 (
1413 "RK_CONFIG_PROTECTION_TITLE_CHECK",
1414 protection.title_check.clone().into(),
1415 ),
1416 (
1417 "RK_CONFIG_PROTECTION_TAG_PATTERN",
1418 protection.tag_pattern.clone().into(),
1419 ),
1420 (
1421 "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
1422 array(&protection.bypass_actors),
1423 ),
1424 (
1425 "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
1426 array(&protection.allowed_merge_methods),
1427 ),
1428 (
1429 "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
1430 protection.strict_required_status_checks.into(),
1431 ),
1432 (
1433 "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
1434 array(&protection.owned_trunk_rules),
1435 ),
1436 (
1437 "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
1438 protection.required_approving_review_count.into(),
1439 ),
1440 (
1441 "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
1442 protection.dismiss_stale_reviews_on_push.into(),
1443 ),
1444 (
1445 "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
1446 protection.require_code_owner_review.into(),
1447 ),
1448 (
1449 "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
1450 protection.require_last_push_approval.into(),
1451 ),
1452 (
1453 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
1454 protection.github.squash_title_source.clone().into(),
1455 ),
1456 (
1457 "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
1458 protection.github.squash_body_source.clone().into(),
1459 ),
1460 (
1461 "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
1462 protection.gitlab.merge_method.clone().into(),
1463 ),
1464 (
1465 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
1466 protection.gitlab.squash_option.clone().into(),
1467 ),
1468 (
1469 "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
1470 protection.gitlab.squash_commit_template.clone().into(),
1471 ),
1472 (
1473 "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
1474 protection.gitlab.push_access_level.into(),
1475 ),
1476 (
1477 "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
1478 protection.gitlab.merge_access_level.into(),
1479 ),
1480 ]
1481}
1482
1483const DERIVED_POLICY_KEYS: [&str; 5] = [
1494 "protection.bypass_actors",
1495 "protection.owned_trunk_rules",
1496 "protection.gitlab.push_access_level",
1497 "protection.allowed_merge_methods",
1498 "protection.gitlab.squash_option",
1499];
1500
1501const PARAMETER_KEYS: [&str; 30] = [
1503 "project.repo",
1504 "profile.technologies",
1505 "profile.forge",
1506 "profile.release.mode",
1507 "profile.release.driver",
1508 "profile.release.style",
1509 "profile.release.line_prefix",
1510 "profile.release.registry",
1511 "profile.release.build.command",
1512 "profile.release.build.packages",
1513 "git.trunk",
1514 "git.checkout_mode",
1515 "git.integration",
1516 "git.merge_form",
1517 "protection.allowed_merge_methods",
1520 "protection.gitlab.squash_option",
1521 "protection.bypass_actors",
1525 "protection.owned_trunk_rules",
1526 "protection.gitlab.push_access_level",
1527 "capabilities.nix_packaging",
1528 "capabilities.reporting_policy",
1529 "capabilities.scorecard",
1530 "capabilities.code_scanning",
1531 "capabilities.workflow_guards",
1532 "security.contact",
1533 "security.response",
1534 "security.scope",
1535 "setup.required_check",
1538 "setup.required_workflow",
1539 "schema_version",
1540];
1541
1542pub fn with_acquisition(target: &Path, answer: &Acquisition) -> Result<(String, bool), RkError> {
1554 let path = target.join(CONFIG_PATH);
1555 let text = match std::fs::read_to_string(&path) {
1556 Ok(text) => text,
1557 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1558 Err(error) => return Err(error.into()),
1559 };
1560 if text.is_empty() {
1561 let template = crate::embedded::BLOCKS
1562 .get_file("acquisition-config.toml.in")
1563 .and_then(include_dir::File::contents_utf8)
1564 .ok_or_else(|| invalid("the binary lacks its acquisition template"))?;
1565 let mut fresh = template.as_bytes().to_vec();
1566 for (token, value) in [
1567 (
1568 "RK_CONFIG_SCHEMA_VERSION",
1569 toml_edit::Value::from(SCHEMA_VERSION),
1570 ),
1571 (
1572 "RK_CONFIG_ACQUISITION_SOURCE",
1573 toml_edit::Value::from(answer.source.as_str()),
1574 ),
1575 (
1576 "RK_CONFIG_ACQUISITION_MINIMUM",
1577 toml_edit::Value::from(answer.minimum.clone()),
1578 ),
1579 ] {
1580 fresh =
1581 crate::landing::substitute(&fresh, token.as_bytes(), value.to_string().as_bytes());
1582 }
1583 let fresh = String::from_utf8(fresh).map_err(|error| invalid(error.to_string()))?;
1584 parse(&fresh)?;
1585 return Ok((fresh, true));
1586 }
1587 let held = parse(&text)?;
1588 if held.acquisition.as_ref() == Some(answer) {
1589 return Ok((text, false));
1590 }
1591 let text = current_text(&text)?;
1592 let mut document = text
1593 .parse::<toml_edit::DocumentMut>()
1594 .map_err(|error| invalid(error.to_string()))?;
1595 if !document.contains_table("acquisition") {
1596 let mut table = toml_edit::Table::new();
1597 table.decor_mut().set_prefix("\n");
1598 document.insert("acquisition", toml_edit::Item::Table(table));
1599 }
1600 let table = document
1601 .get_mut("acquisition")
1602 .and_then(toml_edit::Item::as_table_mut)
1603 .ok_or_else(|| invalid("acquisition must be a table"))?;
1604 for (key, value, note) in [
1605 (
1606 "source",
1607 answer.source.as_str().to_owned(),
1608 " P: host where every host installs rk itself; absent where a manager file pins it",
1609 ),
1610 (
1611 "minimum",
1612 answer.minimum.clone(),
1613 " P: the lowest rk release a host may run against this project",
1614 ),
1615 ] {
1616 let kept = table
1617 .get(key)
1618 .and_then(toml_edit::Item::as_value)
1619 .map(|held| held.decor().clone());
1620 let mut value = toml_edit::Value::from(value);
1621 match kept {
1622 Some(decor) => *value.decor_mut() = decor,
1623 None => value.decor_mut().set_suffix(format!(" #{note}")),
1624 }
1625 table.insert(key, toml_edit::Item::Value(value));
1626 }
1627 let next = document.to_string();
1628 parse(&next)?;
1629 Ok((next, true))
1630}
1631
1632impl Config {
1633 #[must_use]
1636 pub fn states_only_a_migration(&self) -> bool {
1637 self.migration.is_some()
1638 && Self {
1639 migration: None,
1640 ..self.clone()
1641 } == Self::default()
1642 }
1643}
1644
1645pub fn with_migration(text: &str, id: Option<&str>) -> Result<String, RkError> {
1652 let mut document = text
1653 .parse::<toml_edit::DocumentMut>()
1654 .map_err(|error| invalid(error.to_string()))?;
1655 let root = document.as_table_mut();
1656 match id {
1657 Some(id) => {
1658 root.insert("migration", toml_edit::value(id));
1659 root.sort_values_by(|a, _, b, _| {
1661 let rank = |key: &toml_edit::Key| match key.get() {
1662 "schema_version" => 0,
1663 "migration" => 1,
1664 _ => 2,
1665 };
1666 rank(a).cmp(&rank(b))
1667 });
1668 }
1669 None => {
1670 root.remove("migration");
1671 }
1672 }
1673 let next = document.to_string();
1674 parse(&next)?;
1675 Ok(next)
1676}
1677
1678pub fn check_writable(target: &Path) -> Result<(), RkError> {
1688 let file = target.join(CONFIG_PATH);
1689 for (path, directory) in [(file.parent(), true), (Some(file.as_path()), false)] {
1690 let Some(path) = path else { continue };
1691 let Ok(metadata) = std::fs::symlink_metadata(path) else {
1692 continue;
1693 };
1694 let kind = metadata.file_type();
1695 let fits = if directory {
1696 kind.is_dir()
1697 } else {
1698 kind.is_file()
1699 };
1700 if !fits {
1701 return Err(RkError::refusal(
1702 Diagnostic::new(
1703 Reason::StateDrift,
1704 format!(
1705 "{} is a symlink or not a regular {}, so a configuration write would not stay in the target",
1706 path.display(),
1707 if directory { "directory" } else { "file" }
1708 ),
1709 )
1710 .expected(format!("{CONFIG_PATH} as a regular file in a regular directory"))
1711 .action("replace the symlink with the directory or file it stands for")
1712 .target_state("unchanged"),
1713 ));
1714 }
1715 }
1716 Ok(())
1717}
1718
1719pub fn set_migration(target: &Path, id: Option<&str>) -> Result<(), RkError> {
1727 check_writable(target)?;
1728 let path = target.join(CONFIG_PATH);
1729 let text = match std::fs::read_to_string(&path) {
1730 Ok(text) => text,
1731 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1732 if id.is_none() {
1733 return Ok(());
1734 }
1735 format!("schema_version = {SCHEMA_VERSION}\n")
1736 }
1737 Err(error) => return Err(error.into()),
1738 };
1739 let next = with_migration(¤t_text(&text)?, id)?;
1740 write_checked(target, next.as_bytes())
1741}
1742
1743pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
1749 let path = target.join(CONFIG_PATH);
1750 let text = std::fs::read_to_string(&path)?;
1751 let next = rewrite_text(&text, key, Some(value))?;
1752 write_checked(target, next.as_bytes())
1753}
1754
1755fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
1758 rewrite_all(text, vec![(key, value)])
1759}
1760
1761fn rewrite_all(
1769 text: &str,
1770 values: Vec<(&str, Option<toml_edit::Value>)>,
1771) -> Result<String, RkError> {
1772 for (key, _) in &values {
1773 if !PARAMETER_KEYS.contains(key) {
1774 return Err(invalid(format!("{key} is not a landing parameter")));
1775 }
1776 }
1777 parse(text)?;
1778 let text = current_text(text)?;
1779 let mut document = text
1780 .parse::<toml_edit::DocumentMut>()
1781 .map_err(|error| invalid(error.to_string()))?;
1782 let mut changed = false;
1783 let mut emptied: Vec<&str> = Vec::new();
1786 for (key, value) in values {
1787 let removing = value.is_none();
1788 let applied = apply_key(&mut document, key, value)?;
1789 changed |= applied;
1790 if applied
1791 && removing
1792 && let Some(parent) = key.split('.').next()
1793 {
1794 emptied.push(parent);
1795 }
1796 }
1797 changed |= prune_empty_tables(&mut document, &emptied);
1798 changed |= migrate::refresh_comments(&mut document);
1805 if !changed {
1806 return Ok(text);
1807 }
1808 let next = document.to_string();
1809 parse(&next)?;
1810 Ok(next)
1811}
1812
1813pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1832 let order: Vec<String> = document
1833 .as_table()
1834 .iter()
1835 .map(|(key, _)| key.to_owned())
1836 .collect();
1837 let mut changed = false;
1838 for (index, name) in order.iter().enumerate() {
1839 if !names.contains(&name.as_str())
1840 || !document
1841 .get(name)
1842 .and_then(toml_edit::Item::as_table)
1843 .is_some_and(toml_edit::Table::is_empty)
1844 {
1845 continue;
1846 }
1847 let carried = document
1848 .get(name)
1849 .and_then(toml_edit::Item::as_table)
1850 .and_then(|table| carried_comment(table.decor()));
1851 document.remove(name);
1852 changed = true;
1853 let Some(carried) = carried else { continue };
1854 let next = order
1858 .iter()
1859 .skip(index + 1)
1860 .find(|name| {
1861 document
1862 .get(name)
1863 .and_then(toml_edit::Item::as_table)
1864 .is_some_and(|table| !table.is_implicit())
1865 })
1866 .cloned();
1867 if let Some(next) = next
1868 && let Some(table) = document
1869 .get_mut(&next)
1870 .and_then(toml_edit::Item::as_table_mut)
1871 {
1872 let existing = table
1873 .decor()
1874 .prefix()
1875 .and_then(toml_edit::RawString::as_str)
1876 .unwrap_or_default()
1877 .trim_start_matches('\n')
1878 .to_owned();
1879 table
1880 .decor_mut()
1881 .set_prefix(format!("\n{carried}{existing}"));
1882 } else {
1883 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1884 if !trailing.is_empty() && !trailing.ends_with('\n') {
1885 trailing.push('\n');
1886 }
1887 trailing.push_str(&carried);
1888 document.set_trailing(trailing);
1889 }
1890 }
1891 changed
1892}
1893
1894fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1901 let mut lines = String::new();
1902 for raw in [decor.prefix(), decor.suffix()] {
1903 let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1904 continue;
1905 };
1906 for line in text
1907 .lines()
1908 .map(str::trim)
1909 .filter(|line| line.starts_with('#'))
1910 .filter(|line| !is_template_comment(line))
1911 {
1912 lines.push_str(line);
1913 lines.push('\n');
1914 }
1915 }
1916 (!lines.is_empty()).then_some(lines)
1917}
1918
1919fn is_template_comment(line: &str) -> bool {
1928 let rest = line.trim_start_matches('#').trim_start();
1929 ["P:", "N:", "F:"]
1930 .iter()
1931 .any(|marker| rest.starts_with(marker))
1932}
1933
1934pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1942 let first = document
1943 .as_table()
1944 .iter()
1945 .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1946 .map(|(name, _)| name.to_owned());
1947 if let Some(first) = first
1948 && let Some(table) = document
1949 .get_mut(&first)
1950 .and_then(toml_edit::Item::as_table_mut)
1951 {
1952 let existing = table
1953 .decor()
1954 .prefix()
1955 .and_then(toml_edit::RawString::as_str)
1956 .unwrap_or_default()
1957 .trim_start_matches('\n')
1958 .to_owned();
1959 table
1960 .decor_mut()
1961 .set_prefix(format!("\n{carried}{existing}"));
1962 return;
1963 }
1964 let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1965 if !trailing.is_empty() && !trailing.ends_with('\n') {
1966 trailing.push('\n');
1967 }
1968 trailing.push_str(carried);
1969 document.set_trailing(trailing);
1970}
1971
1972pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1978 let (name, _) = table.get_key_value(key)?;
1979 carried_comment(name.leaf_decor())
1980}
1981
1982pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1984 let Some(mut name) = table.key_mut(key) else {
1985 return;
1986 };
1987 let decor = name.leaf_decor_mut();
1988 let existing = decor
1989 .prefix()
1990 .and_then(toml_edit::RawString::as_str)
1991 .unwrap_or_default()
1992 .trim_start_matches('\n')
1993 .to_owned();
1994 decor.set_prefix(format!("\n{carried}{existing}"));
1995}
1996
1997#[must_use]
1999pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
2000 known
2001 .iter()
2002 .min_by_key(|name| distance(unknown, name))
2003 .copied()
2004}
2005
2006pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
2009 let carried = carried_comment(table.decor())?;
2010 table.decor_mut().set_prefix("\n");
2011 Some(carried)
2012}
2013
2014pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
2025 let carried = table.get_key_value(key).and_then(|(name, item)| {
2026 let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
2027 if let Some(value) = item.as_value()
2028 && let Some(more) = carried_comment(value.decor())
2029 {
2030 lines.push_str(&more);
2031 }
2032 (!lines.is_empty()).then_some(lines)
2033 });
2034 let removed = table.remove(key).is_some();
2035 if let Some(carried) = carried {
2036 let existing = table
2037 .decor()
2038 .prefix()
2039 .and_then(toml_edit::RawString::as_str)
2040 .unwrap_or_default()
2041 .trim_start_matches('\n')
2042 .to_owned();
2043 table
2044 .decor_mut()
2045 .set_prefix(format!("\n{carried}{existing}"));
2046 }
2047 removed
2048}
2049
2050fn apply_key(
2053 document: &mut toml_edit::DocumentMut,
2054 key: &str,
2055 value: Option<toml_edit::Value>,
2056) -> Result<bool, RkError> {
2057 let segments: Vec<&str> = key.split('.').collect();
2058 let Some((last, parents)) = segments.split_last() else {
2061 return Err(invalid(format!("{key} names no key")));
2062 };
2063 let Some(mut value) = value else {
2064 let mut item = document.as_item_mut();
2065 for segment in parents {
2066 if item.get(segment).is_none() {
2067 return Ok(false);
2068 }
2069 item = &mut item[segment];
2070 }
2071 let removed = item
2072 .as_table_mut()
2073 .is_some_and(|table| take_comments(table, last));
2074 return Ok(removed);
2075 };
2076 let mut item = document.as_item_mut();
2077 for segment in parents {
2078 if item.get(segment).is_none() {
2079 let mut table = toml_edit::Table::new();
2080 table.set_implicit(true);
2081 item[segment] = toml_edit::Item::Table(table);
2082 }
2083 item = &mut item[segment];
2084 }
2085 if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
2086 if same_value(old, &value) {
2087 return Ok(false);
2088 }
2089 *value.decor_mut() = old.decor().clone();
2090 } else if let Some(comment) = migrate::template_comment(&segments) {
2091 value.decor_mut().set_suffix(comment);
2097 }
2098 item[last] = toml_edit::Item::Value(value);
2099 Ok(true)
2100}
2101
2102fn same_value(old: &toml_edit::Value, new: &toml_edit::Value) -> bool {
2106 let parsed = |value: &toml_edit::Value| {
2107 format!("v = {}", value.to_string().trim()).parse::<toml::Table>()
2108 };
2109 matches!((parsed(old), parsed(new)), (Ok(old), Ok(new)) if old == new)
2110}
2111
2112#[derive(Debug, Clone, serde::Serialize)]
2114pub struct Plan {
2115 pub action: &'static str,
2117 pub changes: Vec<String>,
2119 pub content: String,
2121}
2122
2123impl Plan {
2124 pub fn new(
2129 target: &Path,
2130 params: &crate::landing::Params,
2131 existing: Option<&Config>,
2132 record: Option<&crate::landing::manifest::Manifest>,
2133 ) -> Result<Self, RkError> {
2134 let text = existing
2135 .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
2136 .transpose()?;
2137 Self::compose(text.as_deref(), params, existing, record)
2138 }
2139
2140 pub fn compose(
2147 text: Option<&str>,
2148 params: &crate::landing::Params,
2149 existing: Option<&Config>,
2150 record: Option<&crate::landing::manifest::Manifest>,
2151 ) -> Result<Self, RkError> {
2152 let mut resolved = existing.cloned().unwrap_or_default();
2153 resolved.schema_version = SCHEMA_VERSION;
2154 params.repo().clone_into(&mut resolved.project.repo);
2155 resolved.profile = Profile {
2156 technologies: Some(params.technologies().to_vec()),
2157 forge: Some(params.forge().unwrap_or_default().to_owned()),
2158 release: Release {
2159 mode: Some(params.release_mode()),
2160 driver: params.driver().map(str::to_owned),
2161 style: params.style(),
2162 line_prefix: params.profile().release.line_prefix.clone(),
2163 registry: params.profile().release.registry,
2164 build: params.profile().release.build.clone(),
2165 },
2166 };
2167 resolved.git = Git {
2168 trunk: Some(params.trunk().to_owned()),
2169 checkout_mode: Some(params.checkout_mode()),
2170 integration: Some(params.integration()),
2171 merge_form: Some(params.merge_form()),
2172 };
2173 resolved.protection = with_merge_form(
2174 &protection_for(
2175 &resolved.protection,
2176 params.integration(),
2177 existing.is_none(),
2178 ),
2179 params.merge_form(),
2180 );
2181 resolved.capabilities = Capabilities {
2182 nix_packaging: Some(params.nix_packaging()),
2183 reporting_policy: Some(params.reporting_policy()),
2184 scorecard: Some(params.scorecard()),
2185 code_scanning: Some(
2186 params
2187 .code_scanning()
2188 .map_or("off", crate::landing::Provider::as_str)
2189 .to_owned(),
2190 ),
2191 workflow_guards: Some(params.workflow_guards()),
2192 };
2193 if params.release_mode() != crate::profile::ReleaseMode::None || params.reporting_policy() {
2197 resolved.security.contact = Some(params.security_contact().to_owned());
2198 resolved.security.response = Some(params.security_response().to_owned());
2199 resolved.security.scope = Some(params.security_scope().to_owned());
2200 }
2201 params
2202 .required_check()
2203 .clone_into(&mut resolved.setup.required_check);
2204 params
2205 .required_workflow()
2206 .clone_into(&mut resolved.setup.required_workflow);
2207 let bare = existing.is_some_and(Config::states_only_a_migration);
2212 let content = if let Some(text) = text.filter(|_| existing.is_some() && !bare) {
2213 rewrite_all(text, parameter_values(&resolved))?
2214 } else {
2215 let rendered =
2216 String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?;
2217 with_migration(&rendered, resolved.migration.as_deref())?
2218 };
2219 parse(&content)?;
2220 Ok(Self {
2221 action: if existing.is_some() {
2222 "updated"
2223 } else {
2224 "added"
2225 },
2226 changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
2227 content,
2228 })
2229 }
2230
2231 pub fn apply(&self, target: &Path) -> Result<(), RkError> {
2237 write_checked(target, self.content.as_bytes())
2238 }
2239}
2240
2241#[allow(
2243 clippy::too_many_lines,
2244 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"
2245)]
2246fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
2247 let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
2248 "project.repo",
2249 (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
2250 )];
2251 if let Some(list) = &config.profile.technologies {
2252 values.push(("profile.technologies", Some(array(list))));
2253 }
2254 if let Some(forge) = config.profile.forge.clone() {
2255 values.push(("profile.forge", Some(forge.into())));
2256 }
2257 if let Some(mode) = config.profile.release.mode {
2258 values.push(("profile.release.mode", Some(mode.as_str().into())));
2259 values.push((
2260 "profile.release.driver",
2261 config
2262 .profile
2263 .release
2264 .driver
2265 .clone()
2266 .map(toml_edit::Value::from),
2267 ));
2268 values.push((
2269 "profile.release.style",
2270 config
2271 .profile
2272 .release
2273 .style
2274 .map(|style| style.as_str().into()),
2275 ));
2276 values.push((
2277 "profile.release.line_prefix",
2278 config
2279 .profile
2280 .release
2281 .line_prefix
2282 .clone()
2283 .map(toml_edit::Value::from),
2284 ));
2285 values.push((
2286 "profile.release.registry",
2287 config
2288 .profile
2289 .release
2290 .registry
2291 .map(|answer| answer.as_str().into()),
2292 ));
2293 let build = config.profile.release.build.as_ref();
2294 values.push((
2295 "profile.release.build.command",
2296 build
2297 .and_then(|build| build.command.clone())
2298 .map(toml_edit::Value::from),
2299 ));
2300 values.push((
2301 "profile.release.build.packages",
2302 build
2303 .and_then(|build| build.packages.clone())
2304 .map(toml_edit::Value::from),
2305 ));
2306 }
2307 if let Some(trunk) = config.git.trunk.clone() {
2308 values.push(("git.trunk", Some(trunk.into())));
2309 }
2310 if let Some(mode) = config.git.checkout_mode {
2311 values.push(("git.checkout_mode", Some(mode.as_str().into())));
2312 }
2313 if let Some(mode) = config.git.integration {
2314 values.push(("git.integration", Some(mode.as_str().into())));
2315 let mut bypass = toml_edit::Array::new();
2319 for actor in &config.protection.bypass_actors {
2320 bypass.push(actor.as_str());
2321 }
2322 values.push((
2323 "protection.bypass_actors",
2324 Some(toml_edit::Value::Array(bypass)),
2325 ));
2326 let mut rules = toml_edit::Array::new();
2327 for rule in &config.protection.owned_trunk_rules {
2328 rules.push(rule.as_str());
2329 }
2330 values.push((
2331 "protection.owned_trunk_rules",
2332 Some(toml_edit::Value::Array(rules)),
2333 ));
2334 values.push((
2335 "protection.gitlab.push_access_level",
2336 Some(config.protection.gitlab.push_access_level.into()),
2337 ));
2338 }
2339 if let Some(form) = config.git.merge_form {
2340 values.push(("git.merge_form", Some(form.as_str().into())));
2341 let mut methods = toml_edit::Array::new();
2346 for method in &config.protection.allowed_merge_methods {
2347 methods.push(method.as_str());
2348 }
2349 values.push((
2350 "protection.allowed_merge_methods",
2351 Some(toml_edit::Value::Array(methods)),
2352 ));
2353 values.push((
2354 "protection.gitlab.squash_option",
2355 Some(config.protection.gitlab.squash_option.clone().into()),
2356 ));
2357 }
2358 if let Some(value) = config.capabilities.nix_packaging {
2359 values.push(("capabilities.nix_packaging", Some(value.into())));
2360 }
2361 if let Some(value) = config.capabilities.reporting_policy {
2362 values.push(("capabilities.reporting_policy", Some(value.into())));
2363 }
2364 if let Some(value) = config.capabilities.scorecard {
2365 values.push(("capabilities.scorecard", Some(value.into())));
2366 }
2367 if let Some(value) = config.capabilities.code_scanning.clone() {
2368 values.push(("capabilities.code_scanning", Some(value.into())));
2369 }
2370 if let Some(value) = config.capabilities.workflow_guards {
2371 values.push(("capabilities.workflow_guards", Some(value.into())));
2372 }
2373 if let Some(value) = config.security.contact.clone() {
2376 values.push(("security.contact", Some(value.into())));
2377 }
2378 if let Some(value) = config.security.response.clone() {
2379 values.push(("security.response", Some(value.into())));
2380 }
2381 if let Some(value) = config.security.scope.clone() {
2382 values.push(("security.scope", Some(value.into())));
2383 }
2384 values.push((
2389 "setup.required_check",
2390 Some(config.setup.required_check.clone().into()),
2391 ));
2392 values.push((
2393 "setup.required_workflow",
2394 Some(config.setup.required_workflow.clone().into()),
2395 ));
2396 values
2397}
2398
2399#[must_use]
2401pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
2402 let recorded = {
2403 let params = crate::landing::Params::from_record(record);
2404 Plan::compose(None, ¶ms, None, None)
2405 .ok()
2406 .and_then(|plan| parse(&plan.content).ok())
2407 };
2408 let Some(recorded) = recorded else {
2409 return Vec::new();
2410 };
2411 let render = |value: &Option<toml_edit::Value>| {
2412 value.as_ref().map_or_else(
2413 || "<absent>".to_owned(),
2414 |value| value.to_string().trim().to_owned(),
2415 )
2416 };
2417 let baseline: Vec<(&str, String)> = parameter_values(&recorded)
2418 .iter()
2419 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2420 .map(|(key, value)| (*key, render(value)))
2421 .collect();
2422 let mut keys: Vec<String> = parameter_values(config)
2423 .into_iter()
2424 .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2425 .map(|(key, value)| (key, render(&value)))
2426 .filter(|(key, value)| {
2427 baseline
2428 .iter()
2429 .any(|(other, old)| key == other && value != old)
2430 })
2431 .map(|(key, _)| key.to_owned())
2432 .collect();
2433 if let Some(answer) = &config.dev_environment
2437 && answer.clone().answered() != record.parameters.dev_environment
2438 {
2439 keys.push("dev_environment".to_owned());
2440 }
2441 keys
2442}
2443
2444#[must_use]
2452pub(crate) fn local_protection() -> Protection {
2453 const MAINTAINER: i64 = 40;
2455 Protection {
2456 bypass_actors: vec![LOCAL_GITHUB_BYPASS.into()],
2457 gitlab: Gitlab {
2458 push_access_level: MAINTAINER,
2459 ..Gitlab::default()
2460 },
2461 ..Protection::default()
2462 }
2463}
2464
2465#[must_use]
2472pub const fn merge_values(form: MergeForm) -> (&'static str, &'static str) {
2473 match form {
2474 MergeForm::Squash => ("squash", "always"),
2475 MergeForm::Rebase => ("rebase", "never"),
2476 }
2477}
2478
2479#[must_use]
2484pub fn with_merge_form(policy: &Protection, form: MergeForm) -> Protection {
2485 let (method, squash_option) = merge_values(form);
2486 let mut next = policy.clone();
2487 next.allowed_merge_methods = vec![method.to_owned()];
2488 squash_option.clone_into(&mut next.gitlab.squash_option);
2489 next
2490}
2491
2492#[must_use]
2495pub(crate) fn legacy_local_protection(policy: &Protection) -> bool {
2496 policy.bypass_actors.is_empty()
2497 && policy.owned_trunk_rules == ["deletion", "non_fast_forward"]
2498 && policy.gitlab.push_access_level >= 40
2499}
2500
2501#[must_use]
2523fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
2524 let forge = Protection::default();
2525 let local = local_protection();
2526 let tuple = |policy: &Protection| {
2527 (
2528 policy.bypass_actors.clone(),
2529 policy.owned_trunk_rules.clone(),
2530 policy.gitlab.push_access_level,
2531 )
2532 };
2533 let taken = fresh
2534 || tuple(held) == tuple(&forge)
2535 || tuple(held) == tuple(&local)
2536 || legacy_local_protection(held);
2537 if !taken {
2538 return held.clone();
2539 }
2540 let mut next = held.clone();
2541 let source = match integration {
2542 Integration::Forge => forge,
2543 Integration::Local => local,
2544 };
2545 next.bypass_actors = source.bypass_actors;
2546 next.owned_trunk_rules = source.owned_trunk_rules;
2547 next.gitlab.push_access_level = source.gitlab.push_access_level;
2548 next
2549}
2550
2551pub fn trunk_of(target: &Path) -> Result<String, RkError> {
2556 Ok(load(target)?
2557 .and_then(|config| config.git.trunk)
2558 .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
2559}
2560
2561pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
2566 Ok(load(target)?
2567 .and_then(|config| config.profile.release.line_prefix)
2568 .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
2569}
2570
2571#[cfg(test)]
2572mod tests {
2573 use super::{CONFIG_PATH, Config, load, parse, rewrite_key, set_migration, trunk_of, write};
2574 use crate::landing::{CheckoutMode, Integration, MergeForm, Style};
2575 use crate::profile::ReleaseMode;
2576
2577 fn resolved_defaults() -> Config {
2580 Config {
2581 project: super::Project {
2582 repo: "acme/widget".into(),
2583 },
2584 profile: super::Profile {
2585 technologies: Some(vec!["rust".into()]),
2586 forge: Some("github".into()),
2587 release: super::Release {
2588 mode: Some(ReleaseMode::Automatic),
2589 driver: Some("rust".into()),
2590 style: Some(Style::Trunk),
2591 line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
2592 registry: None,
2593 build: None,
2594 },
2595 },
2596 git: super::Git {
2597 trunk: Some(super::TRUNK_DEFAULT.into()),
2598 checkout_mode: Some(CheckoutMode::LinkedWorktree),
2599 integration: Some(Integration::Local),
2600 merge_form: Some(MergeForm::Squash),
2601 },
2602 capabilities: super::Capabilities {
2603 nix_packaging: Some(false),
2604 reporting_policy: Some(true),
2605 scorecard: Some(false),
2606 code_scanning: Some("off".to_owned()),
2607 workflow_guards: Some(false),
2608 },
2609 security: super::Security {
2612 contact: Some(String::new()),
2613 response: Some(super::RESPONSE_DEFAULT.into()),
2614 scope: Some(String::new()),
2615 ..super::Security::default()
2616 },
2617 protection: super::Protection {
2620 trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
2621 safety_ruleset: Some(format!("{}-safety", super::TRUNK_DEFAULT)),
2622 ..super::local_protection()
2623 },
2624 ..Config::default()
2625 }
2626 }
2627
2628 #[test]
2631 fn a_symlinked_configuration_path_refuses_every_writer() {
2632 let outside = tempfile::tempdir().expect("an outside directory exists");
2633 let text = "schema_version = 2\n";
2634 std::fs::write(outside.path().join("config.toml"), text).expect("writes");
2635 let linked_dir = tempfile::tempdir().expect("a tempdir");
2636 std::os::unix::fs::symlink(outside.path(), linked_dir.path().join(".release-kit"))
2637 .expect("links the directory");
2638 let linked_file = tempfile::tempdir().expect("a tempdir");
2639 std::fs::create_dir_all(linked_file.path().join(".release-kit")).expect("mkdir");
2640 std::os::unix::fs::symlink(
2641 outside.path().join("config.toml"),
2642 linked_file.path().join(CONFIG_PATH),
2643 )
2644 .expect("links the file");
2645 for dir in [&linked_dir, &linked_file] {
2646 let target = dir.path();
2647 let refusals = [
2648 write(target, &resolved_defaults()).expect_err("write refuses"),
2649 rewrite_key(target, "git.trunk", "main".into()).expect_err("rewrite refuses"),
2650 set_migration(target, Some("20261009T140205Z")).expect_err("the key refuses"),
2651 ];
2652 for error in refusals {
2653 assert_eq!(error.exit_code(), 73, "{error}");
2654 assert!(error.to_string().contains("symlink"), "{error}");
2655 }
2656 }
2657 assert_eq!(
2658 std::fs::read_to_string(outside.path().join("config.toml")).expect("reads"),
2659 text
2660 );
2661 }
2662
2663 #[test]
2664 fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
2665 let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
2666 let explicit = parse(
2667 "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",
2668 )
2669 .expect("explicit defaults parse");
2670 assert_eq!(omitted.git, super::Git::default());
2671 assert_eq!(omitted.capabilities, super::Capabilities::default());
2672 assert_eq!(
2673 explicit.git.checkout_mode,
2674 Some(CheckoutMode::LinkedWorktree)
2675 );
2676 assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
2677 assert_eq!(explicit.capabilities.nix_packaging, Some(false));
2678 assert_eq!(explicit.capabilities.reporting_policy, Some(true));
2679 assert_eq!(explicit.capabilities.scorecard, Some(false));
2680 assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
2681 assert_ne!(omitted, explicit);
2682 let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
2684 .expect("the older spelling reads");
2685 assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
2686 }
2687
2688 #[test]
2692 fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
2693 let dir = tempfile::tempdir().expect("a tempdir");
2694 std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
2695 std::fs::write(
2696 dir.path().join(CONFIG_PATH),
2697 "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
2698 )
2699 .expect("the config writes");
2700 let held = load(dir.path())
2701 .expect("the config reads")
2702 .expect("it is present");
2703 assert_eq!(held.setup.bot.app_id, "123");
2704 assert_eq!(
2705 held.setup.bot.installation_id,
2706 Some(0),
2707 "the key parses; nothing reads it"
2708 );
2709 }
2710
2711 #[test]
2713 fn a_schema_1_config_migrates_into_its_domains() {
2714 let held = parse(
2715 "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",
2716 )
2717 .expect("a schema 1 file reads");
2718 assert_eq!(held.schema_version, 2);
2719 assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
2720 assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
2721 assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
2722 assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
2723 assert_eq!(held.profile.release.style, Some(Style::Lines));
2724 assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
2725 assert_eq!(held.git.trunk.as_deref(), Some("main"));
2726 assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
2727 assert_eq!(held.capabilities.nix_packaging, Some(false));
2728 assert_eq!(held.capabilities.reporting_policy, Some(true));
2729 }
2730
2731 #[test]
2733 fn every_invalid_release_state_names_its_key() {
2734 for (text, key) in [
2735 (
2736 "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
2737 "profile.release.style",
2738 ),
2739 (
2740 "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
2741 "profile.release.driver",
2742 ),
2743 (
2744 "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
2745 "profile.release.line_prefix",
2746 ),
2747 (
2748 "[profile]\ntechnologies = ['rust', 'rust']\n",
2749 "profile.technologies",
2750 ),
2751 (
2752 "[profile]\ntechnologies = ['Rust!']\n",
2753 "profile.technologies",
2754 ),
2755 ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
2756 (
2757 "[profile.release]\nmode = 'manual'\n",
2758 "profile.release.mode",
2759 ),
2760 ] {
2761 let error = parse(&format!("schema_version = 2\n{text}"))
2762 .expect_err("an invalid release state refuses")
2763 .to_string();
2764 assert!(error.contains(key), "{key}: {error}");
2765 }
2766 }
2767
2768 #[test]
2772 fn a_newly_written_key_takes_the_templates_comment() {
2773 let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
2774 let next = super::rewrite_text(
2775 text,
2776 "git.integration",
2777 Some(toml_edit::Value::from("forge")),
2778 )
2779 .expect("the key writes");
2780 assert!(
2781 next.contains("integration = \"forge\" # P: local or forge"),
2782 "{next}"
2783 );
2784 let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
2786 .expect("the key writes");
2787 assert!(next.contains("trunk = \"master\" # mine"), "{next}");
2788 }
2789
2790 #[test]
2791 fn the_landed_config_template_round_trips() {
2792 let dir = tempfile::tempdir().expect("a target exists");
2793 let mut config = Config::default();
2794 config.project.repo = "acme/nested/widget".into();
2795 config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
2796 config.profile.forge = Some("gitlab".into());
2797 config.profile.release = super::Release {
2798 mode: Some(ReleaseMode::Automatic),
2799 driver: Some("bash".into()),
2800 style: Some(Style::Lines),
2801 line_prefix: Some("stable/".into()),
2802 registry: None,
2803 build: None,
2804 };
2805 config.git.trunk = Some("main".into());
2806 config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
2807 config.git.integration = Some(Integration::Forge);
2808 config.git.merge_form = Some(MergeForm::Rebase);
2809 config.protection = super::with_merge_form(&config.protection, MergeForm::Rebase);
2810 config.capabilities.nix_packaging = Some(true);
2811 config.capabilities.reporting_policy = Some(false);
2812 config.capabilities.scorecard = Some(true);
2813 config.capabilities.code_scanning = Some("semgrep".to_owned());
2814 config.capabilities.workflow_guards = Some(true);
2815 config.security.advisories =
2819 "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
2820 config.security.contact = Some("security team, room 3 \"the vault\"".into());
2821 config.security.response = Some("14 business days".into());
2822 config.security.scope =
2825 Some("Wraps the \"claude\" CLI; report it upstream.\nRK_CONFIG_SECURITY_SCOPE stays literal.".into());
2826 config.setup.required_check = "build / test".into();
2827 config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
2828 config.setup.release_lines = true;
2829 config.setup.excluded_steps = [
2830 (
2831 "package-check".to_owned(),
2832 "nothing is published".to_owned(),
2833 ),
2834 (
2835 "protect-trunk".to_owned(),
2836 "this project merges \"locally\"".to_owned(),
2837 ),
2838 ]
2839 .into_iter()
2840 .collect();
2841 config.setup.bot.app_id = "123".into();
2842 config.protection.trunk_ruleset = Some("primary".into());
2843 config.protection.safety_ruleset = Some("guardrail".into());
2844 config.protection.tag_ruleset = "versions".into();
2845 config.protection.lines_ruleset = "maintenance".into();
2846 config.protection.title_check = "intent".into();
2847 config.protection.tag_pattern = "refs/tags/*".into();
2848 config
2849 .protection
2850 .owned_trunk_rules
2851 .push("required_signatures".into());
2852 config.protection.required_approving_review_count = 2;
2853 config.protection.dismiss_stale_reviews_on_push = true;
2854 config.protection.require_code_owner_review = true;
2855 config.protection.require_last_push_approval = true;
2856 config.protection.gitlab.squash_commit_template =
2857 "%{title}\n\nContext: %{description}".into();
2858 config.protection.gitlab.merge_access_level = 40;
2859 let mut release_less = resolved_defaults();
2862 release_less.project.repo = String::new();
2863 release_less.profile.technologies = Some(Vec::new());
2864 release_less.profile.forge = Some(String::new());
2865 release_less.profile.release = super::Release {
2866 mode: Some(ReleaseMode::None),
2867 driver: None,
2868 style: None,
2869 line_prefix: None,
2870 registry: None,
2871 build: None,
2872 };
2873 release_less.capabilities.reporting_policy = Some(false);
2874 release_less.security.contact = None;
2877 release_less.security.response = None;
2878 release_less.security.scope = None;
2879 for expected in [resolved_defaults(), config, release_less] {
2880 write(dir.path(), &expected).expect("the template renders");
2881 assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
2882 let text =
2883 std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2884 assert!(text.contains("# P: every technology"));
2885 assert!(text.contains("# F: invariant"));
2886 }
2887 let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2888 assert!(
2889 !text.contains("style ="),
2890 "a none release writes no style: {text}"
2891 );
2892 assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
2893 }
2894
2895 #[test]
2896 fn a_config_with_an_unknown_key_refuses_by_name() {
2897 for (table, typo, nearest) in [
2898 ("", "schemax_version", "schema_version"),
2899 ("git", "trunkx", "trunk"),
2900 ("profile.release", "stile", "style"),
2901 ("capabilities", "nix_packagingg", "nix_packaging"),
2902 ("security", "contactx", "contact"),
2903 ("setup", "required_checkx", "required_check"),
2904 ("setup.bot", "app_i", "app_id"),
2905 ("protection", "trunk_rulesett", "trunk_ruleset"),
2906 (
2907 "protection.github",
2908 "squash_body_sourcex",
2909 "squash_body_source",
2910 ),
2911 ("protection.gitlab", "squash_optionx", "squash_option"),
2912 ] {
2913 let header = if table.is_empty() {
2914 String::new()
2915 } else {
2916 format!("[{table}]\n")
2917 };
2918 let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
2919 let error = parse(&text).expect_err("unknown keys refuse").to_string();
2920 for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
2921 assert!(error.contains(expected), "{error}");
2922 }
2923 }
2924 }
2925
2926 #[test]
2930 fn an_exclusion_names_a_real_step_and_states_why() {
2931 for (text, expected) in [
2932 (
2933 "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
2934 vec!["protect-trunkk", "nearest known step: protect-trunk"],
2935 ),
2936 (
2937 "[setup.excluded_steps]\nprotect-trunk = ' '\n",
2938 vec!["protect-trunk", "no reason"],
2939 ),
2940 ] {
2941 let error = parse(&format!("schema_version = 2\n{text}"))
2942 .expect_err("the exclusion refuses")
2943 .to_string();
2944 for want in expected {
2945 assert!(error.contains(want), "{error}");
2946 }
2947 }
2948 let held = parse(
2949 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
2950 )
2951 .expect("a named step with a reason parses");
2952 assert_eq!(
2953 held.setup
2954 .excluded_steps
2955 .get("protect-trunk")
2956 .map(String::as_str),
2957 Some("we merge locally")
2958 );
2959 }
2960
2961 #[test]
2965 fn an_exclusion_does_not_lift_a_floor() {
2966 let error = parse(
2967 "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
2968 )
2969 .expect_err("the floor binds an excluded step's keys too")
2970 .to_string();
2971 assert!(
2972 error.contains("protection.allowed_merge_methods"),
2973 "{error}"
2974 );
2975 }
2976
2977 #[test]
2978 fn a_config_at_an_unknown_schema_refuses() {
2979 for text in ["schema_version = 999", "schema_version = '2'", ""] {
2980 let error = parse(text)
2981 .expect_err("a schema must be declared and known")
2982 .to_string();
2983 assert!(
2984 error.contains(CONFIG_PATH) && error.contains("schema_version"),
2985 "{error}"
2986 );
2987 }
2988 }
2989
2990 #[test]
2991 fn an_unparsable_config_refuses_naming_the_position() {
2992 let error = parse("schema_version = 2\n[project\n")
2993 .expect_err("bad TOML refuses")
2994 .to_string();
2995 for expected in [CONFIG_PATH, "line 2", "column"] {
2996 assert!(error.contains(expected), "{error}");
2997 }
2998 }
2999
3000 #[test]
3001 fn an_absent_config_reads_as_none() {
3002 let dir = tempfile::tempdir().expect("a target exists");
3003 assert_eq!(load(dir.path()).expect("absence is compatible"), None);
3004 assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
3005 }
3006
3007 #[test]
3008 fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
3009 let dir = tempfile::tempdir().expect("a target exists");
3010 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
3011 std::fs::write(
3012 dir.path().join(CONFIG_PATH),
3013 "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
3014 )
3015 .expect("a config exists");
3016 let error =
3017 trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
3018 assert_eq!(error.exit_code(), 73);
3019 assert!(
3020 error
3021 .to_string()
3022 .contains("protection.strict_required_status_checks")
3023 );
3024 }
3025
3026 #[test]
3029 fn an_equal_value_keeps_its_spelling() {
3030 let original = "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'tr\\u0075nk' # ours\n";
3031 let original = original.replace("'tr\\u0075nk'", "\"tr\\u0075nk\"");
3032 let text = super::rewrite_text(&original, "profile.release.style", Some("trunk".into()))
3033 .expect("the style rewrites");
3034 assert_eq!(text, original, "an equal value is rewritten");
3035 let quoted =
3036 "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk' # ours\n";
3037 let text = super::rewrite_text(quoted, "profile.release.style", Some("trunk".into()))
3038 .expect("the style rewrites");
3039 assert_eq!(
3040 text, quoted,
3041 "a literal string equal to the value is rewritten"
3042 );
3043 }
3044
3045 #[test]
3046 fn rewrite_key_preserves_comments() {
3047 let dir = tempfile::tempdir().expect("a target exists");
3048 std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
3049 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";
3050 let path = dir.path().join(CONFIG_PATH);
3051 std::fs::write(&path, original).expect("a config exists");
3052 rewrite_key(dir.path(), "profile.release.style", "lines".into())
3053 .expect("the style writes back");
3054 let text = std::fs::read_to_string(&path).expect("the text reads");
3055 assert_eq!(text, original.replace("'trunk'", "\"lines\""));
3056 assert_eq!(
3057 load(dir.path())
3058 .expect("the config reads")
3059 .expect("present")
3060 .profile
3061 .release
3062 .style,
3063 Some(Style::Lines)
3064 );
3065 rewrite_key(dir.path(), "project.repo", "acme/widget".into())
3066 .expect("an omitted table can be added");
3067 assert_eq!(
3068 load(dir.path())
3069 .expect("reads")
3070 .expect("present")
3071 .project
3072 .repo,
3073 "acme/widget"
3074 );
3075 rewrite_key(
3076 dir.path(),
3077 "security.contact",
3078 "security@acme.example".into(),
3079 )
3080 .expect("the contact is a landing parameter");
3081 rewrite_key(dir.path(), "security.response", "14 days".into())
3082 .expect("the response is a landing parameter");
3083 let held = load(dir.path()).expect("reads").expect("present");
3084 assert_eq!(
3085 held.security.contact.as_deref(),
3086 Some("security@acme.example")
3087 );
3088 assert_eq!(held.security.response.as_deref(), Some("14 days"));
3089 let text = std::fs::read_to_string(&path).expect("the text reads");
3090 assert!(text.contains("# keep me"), "the comment survives: {text}");
3091 let before = std::fs::read(&path).expect("the bytes read");
3092 for (key, value) in [
3093 ("security.advisories", "acme/private"),
3094 ("security.response", "90d"),
3095 ("profile.release.style", "unknown"),
3096 ] {
3097 assert!(rewrite_key(dir.path(), key, value.into()).is_err());
3098 assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
3099 }
3100 }
3101
3102 #[test]
3105 fn the_security_answers_are_held_to_their_grammar() {
3106 for value in ["team@acme.example", " https://acme.example/report ", ""] {
3107 super::canonical_contact(value).expect("a control-free line is a contact");
3108 }
3109 for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
3110 let refusal = super::canonical_contact(value).expect_err("a control character refuses");
3111 assert!(refusal.contains("security.contact"), "{refusal}");
3112 }
3113 assert_eq!(
3114 super::canonical_contact(" team@acme.example "),
3115 Ok("team@acme.example".to_owned()),
3116 "surrounding whitespace is trimmed"
3117 );
3118 for value in [
3119 "best-effort",
3120 "1 day",
3121 "2 days",
3122 "14 days",
3123 "1 business day",
3124 "14 business days",
3125 ] {
3126 assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
3127 }
3128 assert_eq!(
3129 super::canonical_response(""),
3130 Ok(super::RESPONSE_DEFAULT.to_owned()),
3131 "an empty answer reads as the compiled default"
3132 );
3133 for value in [
3134 "0 days",
3135 "1 days",
3136 "2 day",
3137 "+2 days",
3138 "02 days",
3139 "4294967296 days",
3140 "90d",
3141 "two days",
3142 "we answer quickly",
3143 "2 weeks",
3144 ] {
3145 let refusal =
3146 super::canonical_response(value).expect_err("an unstateable window refuses");
3147 assert!(refusal.contains("security.response"), "{value}: {refusal}");
3148 assert!(refusal.contains("business days"), "{value}: {refusal}");
3149 }
3150 let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
3151 .expect_err("the reader refuses it too")
3152 .to_string();
3153 assert!(refusal.contains("security.response"), "{refusal}");
3154 }
3155
3156 #[test]
3159 fn the_security_scope_is_one_paragraph() {
3160 for value in [
3161 "",
3162 "This wraps the claude CLI. Report a flaw in it upstream.",
3163 "One line,\nand a second line of the same paragraph.",
3164 "A #hashtag mid-line and a <comment> stay prose.",
3165 "A dash - and an = sign mid-line,\nand a line ending in --- stay prose.",
3166 ] {
3167 assert_eq!(super::canonical_scope(value), Ok(value.to_owned()));
3168 }
3169 assert_eq!(
3170 super::canonical_scope(" wrapped \n"),
3171 Ok("wrapped".to_owned()),
3172 "surrounding whitespace is trimmed"
3173 );
3174 for (value, why) in [
3175 ("one\n\ntwo", "blank line"),
3176 ("one\n \ntwo", "blank line"),
3177 ("one\r\ntwo", "control character"),
3178 ("one\ttwo", "control character"),
3179 ("# Out of scope", "heading"),
3180 ("intro\n ## Out of scope", "heading"),
3181 ("Out of scope\n===", "underlines a Markdown heading"),
3182 ("Out of scope\n --- ", "underlines a Markdown heading"),
3183 ("see <!-- here", "comment delimiter"),
3184 ("here --> there", "comment delimiter"),
3185 ] {
3186 let refusal =
3187 super::canonical_scope(value).expect_err("a policy-breaking scope refuses");
3188 assert!(refusal.contains("security.scope"), "{value:?}: {refusal}");
3189 assert!(refusal.contains(why), "{value:?}: {refusal}");
3190 }
3191 }
3192
3193 #[test]
3197 fn a_migration_key_that_is_no_identifier_refuses() {
3198 for id in ["20261009T140205Z", "20261009T140205Z-2"] {
3199 let config = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
3200 .expect("an identifier parses");
3201 assert_eq!(config.migration.as_deref(), Some(id));
3202 }
3203 for id in [
3204 "../../elsewhere",
3205 "20261009T140205Z-1",
3206 "",
3207 "20261009T140205\u{e9}",
3208 "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
3209 ] {
3210 let refusal = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
3211 .expect_err("a value that is no identifier refuses")
3212 .to_string();
3213 assert!(
3214 refusal.contains("no migration identifier"),
3215 "{id}: {refusal}"
3216 );
3217 }
3218 }
3219
3220 #[test]
3225 fn a_pruned_header_leaves_no_comment_behind() {
3226 let cases = [
3227 (
3228 "a middle table, comment above",
3229 "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
3230 ),
3231 (
3232 "a middle table, comment inline",
3233 "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
3234 ),
3235 (
3236 "the last table, comment above",
3237 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
3238 ),
3239 (
3240 "the last table, comment inline",
3241 "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
3242 ),
3243 ];
3244 for (case, text) in cases {
3245 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
3246 assert!(!next.contains("repo ="), "{case}: {next}");
3247 assert!(
3248 !next.contains("[project]"),
3249 "{case}: a table every one of whose keys dropped goes with them: {next}"
3250 );
3251 assert!(
3252 next.contains("# the operator's note"),
3253 "{case}: the comment the header carried survives: {next}"
3254 );
3255 parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
3256 }
3257 }
3258
3259 #[test]
3265 fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
3266 let text = concat!(
3267 "schema_version = 2\n\n[project]\n",
3268 "# the operator's note\n",
3269 "repo = \"acme/widget\" # P: project path on the forge\n\n",
3270 "[git]\ntrunk = \"main\"\n"
3271 );
3272 let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
3273 assert!(!next.contains("repo ="), "{next}");
3274 assert!(!next.contains("[project]"), "{next}");
3275 assert!(
3276 next.contains("# the operator's note"),
3277 "authored text survives: {next}"
3278 );
3279 assert!(
3280 !next.contains("# P:"),
3281 "the template's comment describes a key that is gone: {next}"
3282 );
3283 parse(&next).expect("the result parses");
3284
3285 let text = concat!(
3287 "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
3288 "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
3289 "# why this project names its own prefix\n",
3290 "line_prefix = \"stable/\"\n"
3291 );
3292 let next = super::rewrite_text(text, "profile.release.line_prefix", None)
3293 .expect("the key removes");
3294 assert!(!next.contains("line_prefix ="), "{next}");
3295 assert!(next.contains("[profile.release]"), "{next}");
3296 assert!(
3297 next.contains("# why this project names its own prefix"),
3298 "authored text survives: {next}"
3299 );
3300 }
3301}