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