Skip to main content

release_kit/
config.rs

1//! The committed target configuration, parsed strictly and written from
2//! authored text, typed by the domain that owns each answer.
3//! Comparisons continue to use the landing record alone.
4//!
5//! SATISFIES project-profile:the-target-configuration-is-typed-by-domain
6
7pub 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
21/// The committed input, relative to the target root.
22pub const CONFIG_PATH: &str = ".release-kit/config.toml";
23/// The configuration schema this binary writes. Schema 1 reads through
24/// the one migration in [`migrate`].
25pub const SCHEMA_VERSION: i64 = 2;
26
27/// Per-target answers; an omitted table uses its compiled defaults.
28#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
29#[serde(deny_unknown_fields, default)]
30pub struct Config {
31    /// Version of the authored configuration shape.
32    pub schema_version: i64,
33    /// N: the open migration's identifier, which `rk migrate archive`
34    /// sets and `rk migrate close` removes. Absent while none is open, so
35    /// a closed migration leaves the file a greenfield landing writes.
36    pub migration: Option<String>,
37    /// Project identity.
38    pub project: Project,
39    /// What the project is.
40    pub profile: Profile,
41    /// How topic branches reach the trunk.
42    pub git: Git,
43    /// Which optional products the target requests.
44    pub capabilities: Capabilities,
45    /// Report-routing facts and the two policy answers.
46    pub security: Security,
47    /// Forge setup inputs and the setup declaration.
48    pub setup: Setup,
49    /// Names and floored policy.
50    pub protection: Protection,
51    /// How every host obtains `rk`, where no manager file pins it.
52    pub acquisition: Option<Acquisition>,
53    /// How a command runs in the project's own development environment.
54    pub dev_environment: Option<dev_environment::DevEnvironment>,
55}
56
57impl Default for Config {
58    fn default() -> Self {
59        Self {
60            schema_version: SCHEMA_VERSION,
61            migration: None,
62            project: Project::default(),
63            profile: Profile::default(),
64            git: Git::default(),
65            capabilities: Capabilities::default(),
66            security: Security::default(),
67            setup: Setup::default(),
68            protection: Protection::default(),
69            acquisition: None,
70            dev_environment: None,
71        }
72    }
73}
74
75/// Where every host obtains `rk`, where no manager file records a pin.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum AcquisitionSource {
79    /// Each host installs `rk` itself, at the minimum or newer.
80    Host,
81}
82
83impl AcquisitionSource {
84    /// The word the file spells.
85    #[must_use]
86    pub const fn as_str(self) -> &'static str {
87        match self {
88            Self::Host => "host",
89        }
90    }
91}
92
93/// The `acquisition` table: the host-install answer.
94///
95/// A project gives it where it obtains `rk` from each host's own install.
96/// A manager file records its own pin, so only the one method that leaves
97/// no file behind needs this table.
98///
99/// SATISFIES target-config:a-host-install-is-a-recorded-answer
100#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
101#[serde(deny_unknown_fields)]
102pub struct Acquisition {
103    /// P: how every host obtains the binary.
104    pub source: AcquisitionSource,
105    /// P: the lowest `rk` release a host may run against this project.
106    pub minimum: String,
107}
108
109impl Acquisition {
110    /// A host answer whose minimum is this binary's own release.
111    #[must_use]
112    pub fn host_at_this_release() -> Self {
113        Self {
114            source: AcquisitionSource::Host,
115            minimum: env!("CARGO_PKG_VERSION").to_owned(),
116        }
117    }
118}
119
120/// Whether a value is a release version: three numeric parts, then an
121/// optional pre-release or build suffix.
122fn is_release_version(value: &str) -> bool {
123    let core = value.split(['-', '+']).next().unwrap_or_default();
124    let parts: Vec<&str> = core.split('.').collect();
125    parts.len() == 3
126        && parts
127            .iter()
128            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
129        && value[core.len()..]
130            .chars()
131            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
132}
133
134/// The `project` table: identity.
135#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
136#[serde(deny_unknown_fields, default)]
137pub struct Project {
138    /// P: project path on the forge, nested groups included. Empty where
139    /// the project has no forge repository.
140    pub repo: String,
141}
142
143/// The `profile` table: what the project is.
144#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
145#[serde(deny_unknown_fields, default)]
146pub struct Profile {
147    /// P: every technology present, zero or many. Absent means detect.
148    pub technologies: Option<Vec<String>>,
149    /// P: the forge. Absent means detect from the remote; empty states
150    /// that the project has no forge.
151    pub forge: Option<String>,
152    /// P: the release intent.
153    pub release: Release,
154}
155
156/// The `profile.release` table.
157#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
158#[serde(deny_unknown_fields, default)]
159pub struct Release {
160    /// P: automatic, external, or none. Absent means the observation
161    /// proposes one.
162    pub mode: Option<ReleaseMode>,
163    /// P: the technology that states the version and takes the bot;
164    /// automatic alone.
165    pub driver: Option<String>,
166    /// P: trunk or lines; automatic alone.
167    pub style: Option<Style>,
168    /// P: release-line branch prefix; automatic alone.
169    pub line_prefix: Option<String>,
170    /// P: where the crate publishes, `crates-io` or `none`; an automatic
171    /// rust release alone. Absent reads as crates.io.
172    pub registry: Option<crate::profile::RegistryAnswer>,
173}
174
175/// The `git` table: the Git workflow parameters.
176#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
177#[serde(deny_unknown_fields, default)]
178pub struct Git {
179    /// P: the one permanent branch, rendered into every landed artifact
180    /// that names it. Absent means the landing has not answered it, so a
181    /// record's own answer survives an upgrade that predates the key.
182    pub trunk: Option<String>,
183    /// P: linked-worktree or main-worktree.
184    pub checkout_mode: Option<CheckoutMode>,
185    /// P: local or forge, the authority that moves an implementation
186    /// onto the trunk.
187    pub integration: Option<Integration>,
188    /// P: squash or rebase, how the commits of a branch reach the trunk.
189    pub merge_form: Option<MergeForm>,
190}
191
192/// The `capabilities` table: the optional products the target requests.
193#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
194#[serde(deny_unknown_fields, default)]
195pub struct Capabilities {
196    /// P: opt-in Nix capability.
197    pub nix_packaging: Option<bool>,
198    /// P: the landed vulnerability reporting policy.
199    pub reporting_policy: Option<bool>,
200    /// P: opt-in Scorecard capability.
201    pub scorecard: Option<bool>,
202    /// P: opt-in code scanning provider, as `codeql`, `semgrep`, or `off`.
203    /// The value stays a string here so an absent key and an explicit `off`
204    /// stay distinguishable; the resolution parses it.
205    pub code_scanning: Option<String>,
206    /// P: opt-in workflow guards: the agent and commit policy lines, the
207    /// glossary, the workflow hooks, and the two workflow setup steps.
208    pub workflow_guards: Option<bool>,
209}
210
211/// The compiled trunk, used where neither a configuration nor a record answers.
212pub const TRUNK_DEFAULT: &str = "master";
213
214/// The compiled release-line prefix, used where nothing else answers.
215pub const LINE_PREFIX_DEFAULT: &str = "release/";
216
217/// The `security` table.
218#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
219#[serde(deny_unknown_fields, default)]
220pub struct Security {
221    /// N: project receiving vulnerability reports.
222    pub advisories: String,
223    /// P: contact when the forge channel is unavailable, rendered into the
224    /// landed policy. Absent means the landing has not answered it, so a
225    /// record's own answer survives an upgrade that predates the key; an
226    /// explicit empty string resets the policy to the forge's own prose.
227    pub contact: Option<String>,
228    /// P: the acknowledgment window the landed policy promises. Absent
229    /// means unanswered, exactly as `contact` does.
230    pub response: Option<String>,
231    /// P: one paragraph naming what the project does not own, rendered
232    /// after the channel paragraph of the landed policy. Absent means
233    /// unanswered, exactly as `contact` does; an explicit empty string
234    /// renders nothing.
235    pub scope: Option<String>,
236}
237
238/// The compiled response stance, used where nothing else answers: the
239/// policy promises no window at all.
240pub const RESPONSE_DEFAULT: &str = "best-effort";
241
242/// The one GitHub ruleset bypass local integration owns.
243///
244/// GitHub identifies its built-in repository administrator role as role 5.
245/// The setup maps this stable policy name to the forge object, so the target
246/// configuration records intent rather than an API encoding.
247pub const LOCAL_GITHUB_BYPASS: &str = "repository-admin";
248
249/// The canonical form of a security contact, or why it is refused.
250///
251/// One trimmed line. The value is rendered into `SECURITY.md` verbatim, so
252/// a line feed, a carriage return, or any other ASCII control character
253/// would break the sentence it lands in and is refused before any write.
254/// Emptiness is not a refusal: it selects the forge's own authored prose.
255///
256/// # Errors
257/// The refusal text, naming the key and what it accepts.
258pub fn canonical_contact(raw: &str) -> Result<String, String> {
259    let trimmed = raw.trim();
260    if trimmed.chars().any(char::is_control) {
261        return Err(format!(
262            "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:?}"
263        ));
264    }
265    Ok(trimmed.to_owned())
266}
267
268/// The canonical form of a security scope, or why it is refused.
269///
270/// One trimmed paragraph. The value is prose the operator writes, so the
271/// grammar refuses only what breaks the policy it renders into: a blank
272/// line, which would end the paragraph; a control character other than a
273/// line feed; a line that opens a Markdown heading, or that underlines
274/// one; and the comment delimiters the policy's span markers use. Emptiness is not a refusal:
275/// it renders nothing.
276///
277/// # Errors
278/// The refusal text, naming the key and what it accepts.
279pub fn canonical_scope(raw: &str) -> Result<String, String> {
280    let trimmed = raw.trim();
281    let refusal = |why: &str| {
282        format!(
283            "security.scope {why}; it is one paragraph naming what the project does not own, and empty renders nothing, found {trimmed:?}"
284        )
285    };
286    if trimmed.chars().any(|c| c.is_control() && c != '\n') {
287        return Err(refusal(
288            "carries a control character other than a line feed",
289        ));
290    }
291    if trimmed.lines().any(|line| line.trim().is_empty()) {
292        return Err(refusal("carries a blank line"));
293    }
294    if trimmed
295        .lines()
296        .any(|line| line.trim_start().starts_with('#'))
297    {
298        return Err(refusal("carries a line that opens a Markdown heading"));
299    }
300    // A line of `=` or `-` alone underlines the line above it into a
301    // heading, and a `-` line that underlines nothing is a thematic break:
302    // either way the paragraph ends there. No line is empty by now.
303    if trimmed.lines().any(|line| {
304        let line = line.trim();
305        line.chars().all(|c| c == '=') || line.chars().all(|c| c == '-')
306    }) {
307        return Err(refusal(
308            "carries a line of = or - alone, which underlines a Markdown heading",
309        ));
310    }
311    if trimmed.contains("<!--") || trimmed.contains("-->") {
312        return Err(refusal("carries a comment delimiter, <!-- or -->"));
313    }
314    Ok(trimmed.to_owned())
315}
316
317/// The canonical form of a response stance, or why it is refused.
318///
319/// Either `best-effort` or a plural-correct day count: `1 day`, `<n> days`,
320/// `1 business day`, or `<n> business days`, with `n` a `u32` above one
321/// written without a sign or a leading zero. The grammar is narrow because
322/// the rendered sentence is a public promise, and only a value this
323/// renderer can state exactly may reach it. An empty value reads as the
324/// compiled default.
325///
326/// # Errors
327/// The refusal text, naming the key and every accepted form.
328pub fn canonical_response(raw: &str) -> Result<String, String> {
329    let trimmed = raw.trim();
330    if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
331        return Ok(RESPONSE_DEFAULT.to_owned());
332    }
333    let refusal = || {
334        format!(
335            "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:?}"
336        )
337    };
338    let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
339    let plural = match unit {
340        "day" | "business day" => false,
341        "days" | "business days" => true,
342        _ => return Err(refusal()),
343    };
344    let number: u32 = count.parse().map_err(|_| refusal())?;
345    // A canonical count round-trips, which refuses a sign and a leading
346    // zero without a second pass over the text.
347    if count != number.to_string() || (number > 1) != plural {
348        return Err(refusal());
349    }
350    Ok(trimmed.to_owned())
351}
352
353/// The `setup` table.
354#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
355#[serde(deny_unknown_fields, default)]
356pub struct Setup {
357    /// P: the check the trunk ruleset requires in both modes. Under local
358    /// integration the rendered release gate judges it too.
359    pub required_check: String,
360    /// P: the workflow name whose completion wakes the release gate.
361    /// GitHub alone, because a `workflow_run` trigger cannot name a check
362    /// and cannot omit the workflow.
363    pub required_workflow: String,
364    /// N: long-lived branches retired by the trunk.
365    pub retired_branches: Vec<String>,
366    /// N: run release-line protection in a full apply.
367    pub release_lines: bool,
368    /// N: the steps this target does not run, each against the reason a
369    /// report prints. An exclusion narrows what the setup judges and
370    /// weakens no floor: every value a step the target still runs reads is
371    /// floored exactly as before.
372    pub excluded_steps: BTreeMap<String, String>,
373    /// Public bot identity.
374    pub bot: Bot,
375}
376
377impl Default for Setup {
378    fn default() -> Self {
379        Self {
380            required_check: String::new(),
381            required_workflow: String::new(),
382            retired_branches: vec!["main".into(), "develop".into()],
383            release_lines: false,
384            excluded_steps: BTreeMap::new(),
385            bot: Bot::default(),
386        }
387    }
388}
389
390/// The `setup.bot` table.
391///
392/// The App's public identifier and nothing else. The installation id is
393/// not here: it is the forge's own state, one cheap call answers it, and a
394/// cached copy that goes stale buys a refusal the operator must resolve by
395/// hand. The private key and the token are never here at all.
396#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
397#[serde(deny_unknown_fields, default)]
398pub struct Bot {
399    /// N: public App identifier; private credentials stay outside this file.
400    pub app_id: String,
401    /// Accepted and ignored. Version 0.3.13 wrote this key, so a target
402    /// landed by it must still parse; nothing reads the value and no new
403    /// configuration carries it. Removing it outright would refuse every
404    /// such target, because this reader denies an unknown key by design.
405    #[serde(default, skip_serializing)]
406    pub installation_id: Option<i64>,
407}
408
409/// The `protection` table.
410#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
411#[serde(deny_unknown_fields, default)]
412#[allow(
413    clippy::struct_excessive_bools,
414    reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
415)]
416pub struct Protection {
417    /// N: trunk ruleset name. Absent derives `<trunk>-protection`, which
418    /// is the name the setup script built before the key existed, so a
419    /// target that states none keeps the ruleset it already has.
420    pub trunk_ruleset: Option<String>,
421    /// N: safety ruleset name. Absent derives `<trunk>-safety`. This is the
422    /// ruleset that names no bypass actor, so the deletion and force-push
423    /// protections hold against every actor including the one the trunk
424    /// ruleset may excuse.
425    pub safety_ruleset: Option<String>,
426    /// N: tag ruleset name.
427    pub tag_ruleset: String,
428    /// N: release-line ruleset name.
429    pub lines_ruleset: String,
430    /// N: title job context.
431    pub title_check: String,
432    /// F: invariant, covers every published version.
433    pub tag_pattern: String,
434    /// F: invariant, the actors `git.integration` names. It excuses the
435    /// trunk ruleset alone; the safety ruleset names nobody.
436    pub bypass_actors: Vec<String>,
437    /// F: invariant, exactly the method `git.merge_form` names: squash or
438    /// rebase. A landing writes this key with the form.
439    pub allowed_merge_methods: Vec<String>,
440    /// F: invariant, true.
441    pub strict_required_status_checks: bool,
442    /// F: invariant, the rules `git.integration` names. A landing writes
443    /// this key with the authority, and a target that narrowed or
444    /// widened it keeps what it stated.
445    pub owned_trunk_rules: Vec<String>,
446    /// F: floor zero; higher is stricter.
447    pub required_approving_review_count: i64,
448    /// F: floor false; true is stricter.
449    pub dismiss_stale_reviews_on_push: bool,
450    /// F: floor false; true is stricter.
451    pub require_code_owner_review: bool,
452    /// F: floor false; true is stricter.
453    pub require_last_push_approval: bool,
454    /// GitHub policy.
455    pub github: Github,
456    /// GitLab policy.
457    pub gitlab: Gitlab,
458}
459
460impl Default for Protection {
461    fn default() -> Self {
462        Self {
463            trunk_ruleset: None,
464            safety_ruleset: None,
465            tag_ruleset: "release-tags".into(),
466            lines_ruleset: "release-lines".into(),
467            title_check: "pr-title".into(),
468            tag_pattern: "refs/tags/v*".into(),
469            bypass_actors: Vec::new(),
470            allowed_merge_methods: vec!["squash".into()],
471            strict_required_status_checks: true,
472            owned_trunk_rules: vec![
473                "deletion".into(),
474                "non_fast_forward".into(),
475                "pull_request".into(),
476                "required_status_checks".into(),
477            ],
478            required_approving_review_count: 0,
479            dismiss_stale_reviews_on_push: false,
480            require_code_owner_review: false,
481            require_last_push_approval: false,
482            github: Github::default(),
483            gitlab: Gitlab::default(),
484        }
485    }
486}
487
488impl Protection {
489    /// The trunk ruleset's name: the target's own answer, or the name the
490    /// setup script derived before the key existed.
491    #[must_use]
492    pub fn trunk_ruleset(&self, trunk: &str) -> String {
493        self.trunk_ruleset
494            .clone()
495            .unwrap_or_else(|| format!("{trunk}-protection"))
496    }
497
498    /// The safety ruleset's name: the target's own answer, or the derived
499    /// `<trunk>-safety`.
500    ///
501    /// A separate ruleset because a bypass actor is recorded on a ruleset
502    /// and never on a rule. The deletion and force-push rules therefore
503    /// cannot sit beside a bypassed rule without inheriting its excuse.
504    #[must_use]
505    pub fn safety_ruleset(&self, trunk: &str) -> String {
506        self.safety_ruleset
507            .clone()
508            .unwrap_or_else(|| format!("{trunk}-safety"))
509    }
510}
511
512/// The rules the safety ruleset carries, in the order the setup writes them.
513///
514/// These hold against every actor, so they live apart from the rules a
515/// bypass may excuse. `protection.owned_trunk_rules` still names them, and
516/// the floor table still requires them, so one key remains the answer to
517/// what the setup owns on the trunk. The linear-history rule is the third:
518/// the setup derives it from the `rebase` merge form rather than reading it
519/// from that key, and a trunk that keeps only rebase merges keeps it
520/// against every actor, the local integrator's push included.
521pub const SAFETY_RULES: [&str; 3] = ["deletion", "non_fast_forward", LINEAR_HISTORY_RULE];
522
523/// The context the landed commit gate reports under: the job name in
524/// `pr-commits.yml`, which the trunk ruleset requires under the `rebase`
525/// merge form in place of `protection.title_check`.
526pub const COMMIT_GATE_CHECK: &str = "pr-commits";
527
528/// The GitHub rule that refuses a merge commit on the trunk. The `rebase`
529/// merge form requires it, because a rebase merge alone does not stop a
530/// merge commit pushed past the request.
531pub const LINEAR_HISTORY_RULE: &str = "required_linear_history";
532
533/// The rules the trunk ruleset carries: the request and the check it must
534/// carry, which a recorded bypass actor may be excused from.
535pub const REQUEST_RULES: [&str; 2] = ["pull_request", "required_status_checks"];
536
537/// The `protection.github` table.
538#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
539#[serde(deny_unknown_fields, default)]
540pub struct Github {
541    /// F: invariant under `git.merge_form = "squash"`, `PR_TITLE`. No
542    /// squash source applies under `rebase`.
543    pub squash_title_source: String,
544    /// F: invariant under `git.merge_form = "squash"`, `PR_BODY`.
545    pub squash_body_source: String,
546}
547
548impl Default for Github {
549    fn default() -> Self {
550        Self {
551            squash_title_source: "PR_TITLE".into(),
552            squash_body_source: "PR_BODY".into(),
553        }
554    }
555}
556
557/// The `protection.gitlab` table.
558#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
559#[serde(deny_unknown_fields, default)]
560pub struct Gitlab {
561    /// F: invariant, linear history.
562    pub merge_method: String,
563    /// F: invariant, the squash setting `git.merge_form` names: `always`
564    /// under squash and `never` under rebase. A landing writes this key
565    /// with the form.
566    pub squash_option: String,
567    /// F: invariant, references title and description.
568    pub squash_commit_template: String,
569    /// F: invariant, the level `git.integration` names: zero under forge
570    /// integration, which takes no push at all, and the narrowest level
571    /// that admits one under local integration, whose integrations end in
572    /// that push.
573    pub push_access_level: i64,
574    /// F: floor thirty.
575    pub merge_access_level: i64,
576}
577
578impl Default for Gitlab {
579    fn default() -> Self {
580        Self {
581            merge_method: "ff".into(),
582            squash_option: "always".into(),
583            squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
584                .trim_end_matches('\n')
585                .to_owned(),
586            push_access_level: 0,
587            merge_access_level: 40,
588        }
589    }
590}
591
592/// Read the optional file; content errors refuse instead of falling back.
593///
594/// # Errors
595/// Returns a config-invalid refusal for invalid content, and preserves I/O errors.
596pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
597    let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
598        Ok(text) => text,
599        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
600        Err(error) => return Err(error.into()),
601    };
602    parse(&text).map(Some)
603}
604
605/// The text as this binary's schema: a schema 1 file migrated, any other
606/// text as it is, so the strict reader judges the schema afterwards.
607fn current_text(text: &str) -> Result<String, RkError> {
608    let raw: toml::Value =
609        toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
610    match raw.get("schema_version").and_then(toml::Value::as_integer) {
611        Some(1) => migrate::to_schema_2(text),
612        _ => Ok(text.to_owned()),
613    }
614}
615
616/// Refuse a release mode or a registry answer outside its vocabulary.
617///
618/// Each is read as a string first, so a value outside the vocabulary
619/// refuses naming its own key rather than a span and a variant list the
620/// reader must locate for itself.
621fn release_vocabulary(raw: &toml::Value) -> Result<(), RkError> {
622    let release = raw
623        .get("profile")
624        .and_then(|profile| profile.get("release"));
625    if let Some(mode) = release.and_then(|release| release.get("mode")) {
626        let named = mode.as_str().ok_or_else(|| {
627            invalid("profile.release.mode must be a string: automatic, external, or none")
628        })?;
629        crate::profile::ReleaseMode::parse(named)
630            .map_err(|error| invalid(format!("profile.release.mode: {error}")))?;
631    }
632    match release.and_then(|release| release.get("registry")) {
633        Some(registry) if !matches!(registry.as_str(), Some("crates-io" | "none")) => {
634            Err(invalid(format!(
635                "profile.release.registry is {registry}; it takes crates-io or none, where the crate publishes, for an automatic rust release alone"
636            )))
637        }
638        _ => Ok(()),
639    }
640}
641
642/// Refuse a registry answer under a mode or a driver the configuration
643/// names; a driver the resolution derives settles the fit there.
644fn registry_fit(release: &Release) -> Result<(), RkError> {
645    if release.registry.is_some()
646        && (release.driver.is_some()
647            || matches!(
648                release.mode,
649                Some(ReleaseMode::External | ReleaseMode::None)
650            ))
651    {
652        crate::profile::catalog::registry_answer_fits(
653            release.mode.unwrap_or(ReleaseMode::Automatic),
654            release.driver.as_deref(),
655        )
656        .map_err(invalid)?;
657    }
658    Ok(())
659}
660
661fn parse(text: &str) -> Result<Config, RkError> {
662    let text = current_text(text)?;
663    let raw: toml::Value =
664        toml::from_str(&text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
665    if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
666        return Err(invalid(format!(
667            "schema_version must be {SCHEMA_VERSION}, or 1 for a file this binary migrates"
668        )));
669    }
670    release_vocabulary(&raw)?;
671    let config: Config = toml::from_str(&text).map_err(|error: toml::de::Error| {
672        let mut message = error.to_string();
673        if let Some(rest) = error.message().strip_prefix("unknown field `") {
674            let names: Vec<_> = rest.split('`').collect();
675            if let Some(unknown) = names.first()
676                && let Some(nearest) = names
677                    .iter()
678                    .skip(2)
679                    .step_by(2)
680                    .min_by_key(|name| distance(unknown, name))
681            {
682                let _ = write!(message, "; nearest known key: {nearest}");
683            }
684        }
685        invalid(message)
686    })?;
687    if let Some(technologies) = &config.profile.technologies {
688        crate::profile::canonical_list("profile.technologies", technologies).map_err(invalid)?;
689    }
690    if let Some(forge) = &config.profile.forge
691        && !forge.is_empty()
692    {
693        crate::profile::canonical_category(forge)
694            .map_err(|reason| invalid(format!("profile.forge: {reason}")))?;
695    }
696    if let Some(driver) = &config.profile.release.driver {
697        crate::profile::canonical_category(driver)
698            .map_err(|reason| invalid(format!("profile.release.driver: {reason}")))?;
699    }
700    if matches!(
701        config.profile.release.mode,
702        Some(ReleaseMode::External | ReleaseMode::None)
703    ) {
704        let mode = config
705            .profile
706            .release
707            .mode
708            .map_or("none", ReleaseMode::as_str);
709        for (key, present) in [
710            (
711                "profile.release.driver",
712                config.profile.release.driver.is_some(),
713            ),
714            (
715                "profile.release.style",
716                config.profile.release.style.is_some(),
717            ),
718            (
719                "profile.release.line_prefix",
720                config.profile.release.line_prefix.is_some(),
721            ),
722        ] {
723            if present {
724                return Err(invalid(format!(
725                    "{key} is set while profile.release.mode is {mode}; an external or none release names no driver, style, or line prefix"
726                )));
727            }
728        }
729    }
730    registry_fit(&config.profile.release)?;
731    if let Some(contact) = &config.security.contact {
732        canonical_contact(contact).map_err(invalid)?;
733    }
734    if let Some(response) = &config.security.response {
735        canonical_response(response).map_err(invalid)?;
736    }
737    if let Some(scope) = &config.security.scope {
738        canonical_scope(scope).map_err(invalid)?;
739    }
740    exclusions(&config.setup.excluded_steps)?;
741    if let Some(acquisition) = &config.acquisition
742        && !is_release_version(&acquisition.minimum)
743    {
744        return Err(invalid(format!(
745            "acquisition.minimum is {}, which is no release version; write one such as {}",
746            acquisition.minimum,
747            env!("CARGO_PKG_VERSION")
748        )));
749    }
750    if let Some(answer) = &config.dev_environment {
751        answer.check().map_err(invalid)?;
752    }
753    migration_key(&config)?;
754    floors::check(&config)?;
755    Ok(config)
756}
757
758/// Judge the open migration's key: it names a workspace below the state
759/// root, so only an identifier the archive writes may stand there.
760fn migration_key(config: &Config) -> Result<(), RkError> {
761    match &config.migration {
762        Some(id) if !crate::migrate::is_id(id) => Err(invalid(format!(
763            "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)"
764        ))),
765        _ => Ok(()),
766    }
767}
768
769/// Judge the declared exclusions: every id names a step this binary runs,
770/// and every exclusion states why.
771///
772/// A reason is required because the exclusions are the audit trail. A
773/// reader must be able to tell a chosen subset from an incomplete setup,
774/// and a line that names a step and says nothing tells them neither.
775fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
776    for (name, reason) in excluded {
777        if crate::setup::steps::spec(name).is_none() {
778            let nearest = crate::setup::steps::STEPS
779                .iter()
780                .min_by_key(|step| distance(name, step.name))
781                .map_or("", |step| step.name);
782            return Err(invalid(format!(
783                "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
784            )));
785        }
786        if reason.trim().is_empty() {
787            return Err(invalid(format!(
788                "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
789            )));
790        }
791    }
792    Ok(())
793}
794
795pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
796    RkError::refusal(
797        Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
798            .action(format!("edit {CONFIG_PATH} and retry"))
799            .target_state("nothing was written"),
800    )
801}
802
803fn distance(left: &str, right: &str) -> usize {
804    let mut row: Vec<_> = (0..=right.chars().count()).collect();
805    for (i, a) in left.chars().enumerate() {
806        let mut previous = row[0];
807        row[0] = i + 1;
808        for (j, b) in right.chars().enumerate() {
809            let old = row[j + 1];
810            row[j + 1] = (previous + usize::from(a != b))
811                .min(row[j] + 1)
812                .min(old + 1);
813            previous = old;
814        }
815    }
816    row.last().copied().unwrap_or(0)
817}
818
819/// Write the authored template with TOML-escaped scalar substitutions.
820///
821/// # Errors
822/// Returns invalid configuration, a configuration path a symlink redirects,
823/// or I/O failures before or during the atomic write.
824pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
825    let bytes = render(config)?;
826    parse(&String::from_utf8_lossy(&bytes))?;
827    write_checked(target, &bytes)
828}
829
830/// Write `bytes` as the target's configuration, once
831/// [`check_writable`] holds.
832fn write_checked(target: &Path, bytes: &[u8]) -> Result<(), RkError> {
833    check_writable(target)?;
834    crate::atomic::write(&target.join(CONFIG_PATH), bytes)?;
835    Ok(())
836}
837
838fn array(values: &[String]) -> toml_edit::Value {
839    toml_edit::Value::Array(values.iter().collect())
840}
841
842/// The exclusions as the one-line inline table the template carries. A
843/// landing writes an empty one; an operator who wants several may turn it
844/// into a `[setup.excluded_steps]` table, which this reader parses the
845/// same way.
846fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
847    let mut table = toml_edit::InlineTable::new();
848    for (key, value) in values {
849        table.insert(key, value.clone().into());
850    }
851    toml_edit::Value::InlineTable(table)
852}
853
854/// The value of every landing parameter as the template renders it, or
855/// `None` for a key the resolved answers omit: the repository where the
856/// project has no forge, and the automatic-only release keys under
857/// another mode.
858#[allow(
859    clippy::too_many_lines,
860    reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
861)]
862fn fields(config: &Config) -> Result<Vec<(&'static str, Option<toml_edit::Value>)>, RkError> {
863    // The trunk names the ruleset the setup installs, so the written
864    // configuration states the name a target actually gets rather than a
865    // literal that would be wrong for any trunk but the default.
866    let trunk = config
867        .git
868        .trunk
869        .clone()
870        .ok_or_else(|| invalid("git.trunk is unresolved"))?;
871    let technologies = config
872        .profile
873        .technologies
874        .clone()
875        .ok_or_else(|| invalid("profile.technologies is unresolved"))?;
876    let forge = config
877        .profile
878        .forge
879        .clone()
880        .ok_or_else(|| invalid("profile.forge is unresolved"))?;
881    let mode = config
882        .profile
883        .release
884        .mode
885        .ok_or_else(|| invalid("profile.release.mode is unresolved"))?;
886    let mut fields: Vec<(&'static str, Option<toml_edit::Value>)> = vec![
887        (
888            "RK_CONFIG_SCHEMA_VERSION",
889            Some(config.schema_version.into()),
890        ),
891        (
892            "RK_CONFIG_PROJECT_REPO",
893            (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
894        ),
895        ("RK_CONFIG_PROFILE_TECHNOLOGIES", Some(array(&technologies))),
896        ("RK_CONFIG_PROFILE_FORGE", Some(forge.into())),
897        ("RK_CONFIG_PROFILE_RELEASE_MODE", Some(mode.as_str().into())),
898        (
899            "RK_CONFIG_PROFILE_RELEASE_DRIVER",
900            config
901                .profile
902                .release
903                .driver
904                .clone()
905                .map(toml_edit::Value::from),
906        ),
907        (
908            "RK_CONFIG_PROFILE_RELEASE_STYLE",
909            config
910                .profile
911                .release
912                .style
913                .map(|style| style.as_str().into()),
914        ),
915        (
916            "RK_CONFIG_PROFILE_RELEASE_LINE_PREFIX",
917            config
918                .profile
919                .release
920                .line_prefix
921                .clone()
922                .map(toml_edit::Value::from),
923        ),
924        (
925            "RK_CONFIG_PROFILE_RELEASE_REGISTRY",
926            config
927                .profile
928                .release
929                .registry
930                .map(|answer| answer.as_str().into()),
931        ),
932        ("RK_CONFIG_GIT_TRUNK", Some(trunk.clone().into())),
933        (
934            "RK_CONFIG_GIT_CHECKOUT_MODE",
935            Some(
936                config
937                    .git
938                    .checkout_mode
939                    .ok_or_else(|| invalid("git.checkout_mode is unresolved"))?
940                    .as_str()
941                    .into(),
942            ),
943        ),
944        (
945            "RK_CONFIG_GIT_INTEGRATION",
946            Some(
947                config
948                    .git
949                    .integration
950                    .ok_or_else(|| invalid("git.integration is unresolved"))?
951                    .as_str()
952                    .into(),
953            ),
954        ),
955        (
956            "RK_CONFIG_GIT_MERGE_FORM",
957            Some(
958                config
959                    .git
960                    .merge_form
961                    .ok_or_else(|| invalid("git.merge_form is unresolved"))?
962                    .as_str()
963                    .into(),
964            ),
965        ),
966        (
967            "RK_CONFIG_CAPABILITIES_NIX_PACKAGING",
968            Some(
969                config
970                    .capabilities
971                    .nix_packaging
972                    .ok_or_else(|| invalid("capabilities.nix_packaging is unresolved"))?
973                    .into(),
974            ),
975        ),
976        (
977            "RK_CONFIG_CAPABILITIES_REPORTING_POLICY",
978            Some(
979                config
980                    .capabilities
981                    .reporting_policy
982                    .ok_or_else(|| invalid("capabilities.reporting_policy is unresolved"))?
983                    .into(),
984            ),
985        ),
986        (
987            "RK_CONFIG_CAPABILITIES_SCORECARD",
988            Some(
989                config
990                    .capabilities
991                    .scorecard
992                    .ok_or_else(|| invalid("capabilities.scorecard is unresolved"))?
993                    .into(),
994            ),
995        ),
996        (
997            "RK_CONFIG_CAPABILITIES_CODE_SCANNING",
998            Some(
999                config
1000                    .capabilities
1001                    .code_scanning
1002                    .clone()
1003                    .ok_or_else(|| invalid("capabilities.code_scanning is unresolved"))?
1004                    .into(),
1005            ),
1006        ),
1007        (
1008            "RK_CONFIG_CAPABILITIES_WORKFLOW_GUARDS",
1009            Some(
1010                config
1011                    .capabilities
1012                    .workflow_guards
1013                    .ok_or_else(|| invalid("capabilities.workflow_guards is unresolved"))?
1014                    .into(),
1015            ),
1016        ),
1017        (
1018            "RK_CONFIG_SECURITY_ADVISORIES",
1019            Some(config.security.advisories.clone().into()),
1020        ),
1021        (
1022            "RK_CONFIG_SECURITY_CONTACT",
1023            Some(config.security.contact.clone().unwrap_or_default().into()),
1024        ),
1025        (
1026            "RK_CONFIG_SECURITY_RESPONSE",
1027            Some(
1028                config
1029                    .security
1030                    .response
1031                    .clone()
1032                    .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
1033                    .into(),
1034            ),
1035        ),
1036        (
1037            "RK_CONFIG_SECURITY_SCOPE",
1038            Some(config.security.scope.clone().unwrap_or_default().into()),
1039        ),
1040        (
1041            "RK_CONFIG_SETUP_REQUIRED_CHECK",
1042            Some(config.setup.required_check.clone().into()),
1043        ),
1044        (
1045            "RK_CONFIG_SETUP_REQUIRED_WORKFLOW",
1046            Some(config.setup.required_workflow.clone().into()),
1047        ),
1048        (
1049            "RK_CONFIG_SETUP_RETIRED_BRANCHES",
1050            Some(array(&config.setup.retired_branches)),
1051        ),
1052        (
1053            "RK_CONFIG_SETUP_RELEASE_LINES",
1054            Some(config.setup.release_lines.into()),
1055        ),
1056        (
1057            "RK_CONFIG_SETUP_EXCLUDED_STEPS",
1058            Some(inline(&config.setup.excluded_steps)),
1059        ),
1060        (
1061            "RK_CONFIG_SETUP_BOT_APP_ID",
1062            Some(config.setup.bot.app_id.clone().into()),
1063        ),
1064        (
1065            "RK_CONFIG_ACQUISITION_SOURCE",
1066            config
1067                .acquisition
1068                .as_ref()
1069                .map(|answer| answer.source.as_str().into()),
1070        ),
1071        (
1072            "RK_CONFIG_ACQUISITION_MINIMUM",
1073            config
1074                .acquisition
1075                .as_ref()
1076                .map(|answer| answer.minimum.clone().into()),
1077        ),
1078    ];
1079    fields.extend(
1080        protection_fields(&config.protection, trunk.as_str())
1081            .into_iter()
1082            .map(|(token, value)| (token, Some(value))),
1083    );
1084    // A release-less target reads none of the release tables, so its
1085    // configuration leaves them out rather than carrying answers no step
1086    // asks for: the tag protection keys and the bot. The security table
1087    // goes too, unless the target requested the landed reporting policy,
1088    // whose contact and response it states.
1089    if mode == crate::profile::ReleaseMode::None {
1090        let reporting = config.capabilities.reporting_policy.unwrap_or(false);
1091        for (token, value) in &mut fields {
1092            if RELEASE_ONLY_TOKENS.contains(token)
1093                || (!reporting && SECURITY_TOKENS.contains(token))
1094            {
1095                *value = None;
1096            }
1097        }
1098    }
1099    Ok(fields)
1100}
1101
1102/// The template tokens only a release reads: the tag protection keys and
1103/// the bot's App identifier. A configuration whose release mode is `none`
1104/// leaves each one out.
1105///
1106/// SATISFIES target-config:a-release-less-template-carries-no-release-table
1107const RELEASE_ONLY_TOKENS: [&str; 4] = [
1108    "RK_CONFIG_PROTECTION_TAG_RULESET",
1109    "RK_CONFIG_PROTECTION_TAG_PATTERN",
1110    "RK_CONFIG_PROTECTION_LINES_RULESET",
1111    "RK_CONFIG_SETUP_BOT_APP_ID",
1112];
1113
1114/// The `[security]` table's tokens, which a release-less configuration
1115/// leaves out unless it requested the landed reporting policy.
1116const SECURITY_TOKENS: [&str; 4] = [
1117    "RK_CONFIG_SECURITY_ADVISORIES",
1118    "RK_CONFIG_SECURITY_CONTACT",
1119    "RK_CONFIG_SECURITY_RESPONSE",
1120    "RK_CONFIG_SECURITY_SCOPE",
1121];
1122
1123fn render(config: &Config) -> Result<Vec<u8>, RkError> {
1124    let fields = fields(config)?;
1125    let template = crate::embedded::BLOCKS
1126        .get_file("target-config.toml.in")
1127        .and_then(include_dir::File::contents_utf8)
1128        .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
1129    // Each authored line has one token. Substitute in the source line once,
1130    // so a user's string containing another token stays literal. A line
1131    // whose token has no value is dropped: the key is absent rather than
1132    // written empty, and a table every one of whose keys dropped goes with
1133    // its own header rather than standing empty.
1134    let lines: Vec<&str> = template.split_inclusive('\n').collect();
1135    let dropped: Vec<bool> = lines
1136        .iter()
1137        .map(|line| {
1138            matches!(
1139                fields.iter().find(|(token, _)| line.contains(token)),
1140                Some((_, None))
1141            )
1142        })
1143        .collect();
1144    let mut bytes = Vec::new();
1145    let mut at = 0;
1146    while at < lines.len() {
1147        let line = lines[at];
1148        if line.trim_start().starts_with('[') && line.trim_end().ends_with(']') {
1149            let mut end = at + 1;
1150            let mut keeps = false;
1151            while end < lines.len()
1152                && !(lines[end].trim_start().starts_with('[')
1153                    && lines[end].trim_end().ends_with(']'))
1154            {
1155                keeps |=
1156                    fields.iter().any(|(token, _)| lines[end].contains(token)) && !dropped[end];
1157                end += 1;
1158            }
1159            if !keeps {
1160                // The header, its lines, and the blank line that opened it.
1161                while bytes.last() == Some(&b'\n')
1162                    && bytes.len() >= 2
1163                    && bytes[bytes.len() - 2] == b'\n'
1164                {
1165                    bytes.pop();
1166                }
1167                at = end;
1168                continue;
1169            }
1170        }
1171        if !dropped[at] {
1172            match fields.iter().find(|(token, _)| line.contains(token)) {
1173                Some((token, Some(value))) => bytes.extend(crate::landing::substitute(
1174                    line.as_bytes(),
1175                    token.as_bytes(),
1176                    value.to_string().as_bytes(),
1177                )),
1178                Some((_, None)) => {}
1179                None => bytes.extend_from_slice(line.as_bytes()),
1180            }
1181        }
1182        at += 1;
1183    }
1184    Ok(bytes)
1185}
1186
1187fn protection_fields(
1188    protection: &Protection,
1189    trunk: &str,
1190) -> Vec<(&'static str, toml_edit::Value)> {
1191    vec![
1192        (
1193            "RK_CONFIG_PROTECTION_TRUNK_RULESET",
1194            protection.trunk_ruleset(trunk).into(),
1195        ),
1196        (
1197            "RK_CONFIG_PROTECTION_SAFETY_RULESET",
1198            protection.safety_ruleset(trunk).into(),
1199        ),
1200        (
1201            "RK_CONFIG_PROTECTION_TAG_RULESET",
1202            protection.tag_ruleset.clone().into(),
1203        ),
1204        (
1205            "RK_CONFIG_PROTECTION_LINES_RULESET",
1206            protection.lines_ruleset.clone().into(),
1207        ),
1208        (
1209            "RK_CONFIG_PROTECTION_TITLE_CHECK",
1210            protection.title_check.clone().into(),
1211        ),
1212        (
1213            "RK_CONFIG_PROTECTION_TAG_PATTERN",
1214            protection.tag_pattern.clone().into(),
1215        ),
1216        (
1217            "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
1218            array(&protection.bypass_actors),
1219        ),
1220        (
1221            "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
1222            array(&protection.allowed_merge_methods),
1223        ),
1224        (
1225            "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
1226            protection.strict_required_status_checks.into(),
1227        ),
1228        (
1229            "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
1230            array(&protection.owned_trunk_rules),
1231        ),
1232        (
1233            "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
1234            protection.required_approving_review_count.into(),
1235        ),
1236        (
1237            "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
1238            protection.dismiss_stale_reviews_on_push.into(),
1239        ),
1240        (
1241            "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
1242            protection.require_code_owner_review.into(),
1243        ),
1244        (
1245            "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
1246            protection.require_last_push_approval.into(),
1247        ),
1248        (
1249            "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
1250            protection.github.squash_title_source.clone().into(),
1251        ),
1252        (
1253            "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
1254            protection.github.squash_body_source.clone().into(),
1255        ),
1256        (
1257            "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
1258            protection.gitlab.merge_method.clone().into(),
1259        ),
1260        (
1261            "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
1262            protection.gitlab.squash_option.clone().into(),
1263        ),
1264        (
1265            "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
1266            protection.gitlab.squash_commit_template.clone().into(),
1267        ),
1268        (
1269            "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
1270            protection.gitlab.push_access_level.into(),
1271        ),
1272        (
1273            "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
1274            protection.gitlab.merge_access_level.into(),
1275        ),
1276    ]
1277}
1278
1279/// The floored keys a write-back carries beside `git.integration` and
1280/// `git.merge_form`.
1281///
1282/// They are derived companions of the authority rather than recorded
1283/// parameters: the record holds no baseline for them, so a target that
1284/// narrowed either within its floor would read as pending forever and
1285/// route an upgrade that changes nothing.
1286/// `target-config:a-flag-overrides-and-a-landing-writes-back` leaves
1287/// class F policy with its use-time readers, and excluding these from
1288/// what status judges keeps that true.
1289const DERIVED_POLICY_KEYS: [&str; 5] = [
1290    "protection.bypass_actors",
1291    "protection.owned_trunk_rules",
1292    "protection.gitlab.push_access_level",
1293    "protection.allowed_merge_methods",
1294    "protection.gitlab.squash_option",
1295];
1296
1297/// The keys a landing writes back: every class P answer.
1298const PARAMETER_KEYS: [&str; 28] = [
1299    "project.repo",
1300    "profile.technologies",
1301    "profile.forge",
1302    "profile.release.mode",
1303    "profile.release.driver",
1304    "profile.release.style",
1305    "profile.release.line_prefix",
1306    "profile.release.registry",
1307    "git.trunk",
1308    "git.checkout_mode",
1309    "git.integration",
1310    "git.merge_form",
1311    // The two floored keys the merge form decides, written back with it
1312    // for the same reason as the three below.
1313    "protection.allowed_merge_methods",
1314    "protection.gitlab.squash_option",
1315    // The three floored keys the integration authority decides. They are
1316    // written back with it, because changing the authority without them
1317    // leaves a configuration whose own floor table refuses it.
1318    "protection.bypass_actors",
1319    "protection.owned_trunk_rules",
1320    "protection.gitlab.push_access_level",
1321    "capabilities.nix_packaging",
1322    "capabilities.reporting_policy",
1323    "capabilities.scorecard",
1324    "capabilities.code_scanning",
1325    "capabilities.workflow_guards",
1326    "security.contact",
1327    "security.response",
1328    "security.scope",
1329    // The release gate's two answers. One names the check the gate
1330    // believes, the other names the workflow whose completion wakes it.
1331    "setup.required_check",
1332    "setup.required_workflow",
1333    "schema_version",
1334];
1335
1336/// The configuration text with the acquisition answer recorded, and
1337/// whether that changes anything.
1338///
1339/// An existing file keeps every byte but the `acquisition` table. A target
1340/// with no file gets one holding the schema and the answer alone: every
1341/// other table takes its compiled default, and the landing that follows
1342/// rewrites the file rather than replacing it, so the answer survives it.
1343///
1344/// # Errors
1345/// Returns a config-invalid refusal for a file that does not parse, and
1346/// preserves I/O errors.
1347pub fn with_acquisition(target: &Path, answer: &Acquisition) -> Result<(String, bool), RkError> {
1348    let path = target.join(CONFIG_PATH);
1349    let text = match std::fs::read_to_string(&path) {
1350        Ok(text) => text,
1351        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
1352        Err(error) => return Err(error.into()),
1353    };
1354    if text.is_empty() {
1355        let template = crate::embedded::BLOCKS
1356            .get_file("acquisition-config.toml.in")
1357            .and_then(include_dir::File::contents_utf8)
1358            .ok_or_else(|| invalid("the binary lacks its acquisition template"))?;
1359        let mut fresh = template.as_bytes().to_vec();
1360        for (token, value) in [
1361            (
1362                "RK_CONFIG_SCHEMA_VERSION",
1363                toml_edit::Value::from(SCHEMA_VERSION),
1364            ),
1365            (
1366                "RK_CONFIG_ACQUISITION_SOURCE",
1367                toml_edit::Value::from(answer.source.as_str()),
1368            ),
1369            (
1370                "RK_CONFIG_ACQUISITION_MINIMUM",
1371                toml_edit::Value::from(answer.minimum.clone()),
1372            ),
1373        ] {
1374            fresh =
1375                crate::landing::substitute(&fresh, token.as_bytes(), value.to_string().as_bytes());
1376        }
1377        let fresh = String::from_utf8(fresh).map_err(|error| invalid(error.to_string()))?;
1378        parse(&fresh)?;
1379        return Ok((fresh, true));
1380    }
1381    let held = parse(&text)?;
1382    if held.acquisition.as_ref() == Some(answer) {
1383        return Ok((text, false));
1384    }
1385    let text = current_text(&text)?;
1386    let mut document = text
1387        .parse::<toml_edit::DocumentMut>()
1388        .map_err(|error| invalid(error.to_string()))?;
1389    if !document.contains_table("acquisition") {
1390        let mut table = toml_edit::Table::new();
1391        table.decor_mut().set_prefix("\n");
1392        document.insert("acquisition", toml_edit::Item::Table(table));
1393    }
1394    let table = document
1395        .get_mut("acquisition")
1396        .and_then(toml_edit::Item::as_table_mut)
1397        .ok_or_else(|| invalid("acquisition must be a table"))?;
1398    for (key, value, note) in [
1399        (
1400            "source",
1401            answer.source.as_str().to_owned(),
1402            " P: host where every host installs rk itself; absent where a manager file pins it",
1403        ),
1404        (
1405            "minimum",
1406            answer.minimum.clone(),
1407            " P: the lowest rk release a host may run against this project",
1408        ),
1409    ] {
1410        let kept = table
1411            .get(key)
1412            .and_then(toml_edit::Item::as_value)
1413            .map(|held| held.decor().clone());
1414        let mut value = toml_edit::Value::from(value);
1415        match kept {
1416            Some(decor) => *value.decor_mut() = decor,
1417            None => value.decor_mut().set_suffix(format!(" #{note}")),
1418        }
1419        table.insert(key, toml_edit::Item::Value(value));
1420    }
1421    let next = document.to_string();
1422    parse(&next)?;
1423    Ok((next, true))
1424}
1425
1426impl Config {
1427    /// Whether this configuration states an open migration and nothing
1428    /// else: the file the archive writes where none existed.
1429    #[must_use]
1430    pub fn states_only_a_migration(&self) -> bool {
1431        self.migration.is_some()
1432            && Self {
1433                migration: None,
1434                ..self.clone()
1435            } == Self::default()
1436    }
1437}
1438
1439/// The text with the top-level `migration` key set, or removed where `id`
1440/// is `None`, every other byte kept.
1441///
1442/// # Errors
1443///
1444/// Refuses text that is not TOML or not a configuration.
1445pub fn with_migration(text: &str, id: Option<&str>) -> Result<String, RkError> {
1446    let mut document = text
1447        .parse::<toml_edit::DocumentMut>()
1448        .map_err(|error| invalid(error.to_string()))?;
1449    let root = document.as_table_mut();
1450    match id {
1451        Some(id) => {
1452            root.insert("migration", toml_edit::value(id));
1453            // The key sits beside `schema_version`, ahead of every table.
1454            root.sort_values_by(|a, _, b, _| {
1455                let rank = |key: &toml_edit::Key| match key.get() {
1456                    "schema_version" => 0,
1457                    "migration" => 1,
1458                    _ => 2,
1459                };
1460                rank(a).cmp(&rank(b))
1461            });
1462        }
1463        None => {
1464            root.remove("migration");
1465        }
1466    }
1467    let next = document.to_string();
1468    parse(&next)?;
1469    Ok(next)
1470}
1471
1472/// Refuse a configuration path that does not lead straight into the
1473/// target.
1474///
1475/// A symlinked directory or file would carry a write out of the target,
1476/// and a directory or file of another kind stands in its way.
1477///
1478/// # Errors
1479///
1480/// A `state-drift` refusal naming the path.
1481pub fn check_writable(target: &Path) -> Result<(), RkError> {
1482    let file = target.join(CONFIG_PATH);
1483    for (path, directory) in [(file.parent(), true), (Some(file.as_path()), false)] {
1484        let Some(path) = path else { continue };
1485        let Ok(metadata) = std::fs::symlink_metadata(path) else {
1486            continue;
1487        };
1488        let kind = metadata.file_type();
1489        let fits = if directory {
1490            kind.is_dir()
1491        } else {
1492            kind.is_file()
1493        };
1494        if !fits {
1495            return Err(RkError::refusal(
1496                Diagnostic::new(
1497                    Reason::StateDrift,
1498                    format!(
1499                        "{} is a symlink or not a regular {}, so a configuration write would not stay in the target",
1500                        path.display(),
1501                        if directory { "directory" } else { "file" }
1502                    ),
1503                )
1504                .expected(format!("{CONFIG_PATH} as a regular file in a regular directory"))
1505                .action("replace the symlink with the directory or file it stands for")
1506                .target_state("unchanged"),
1507            ));
1508        }
1509    }
1510    Ok(())
1511}
1512
1513/// Set or remove the open migration in the target's configuration,
1514/// writing a file that states the key alone where none exists.
1515///
1516/// # Errors
1517///
1518/// Refuses a configuration path a symlink redirects, an invalid
1519/// configuration, and propagates a write failure.
1520pub fn set_migration(target: &Path, id: Option<&str>) -> Result<(), RkError> {
1521    check_writable(target)?;
1522    let path = target.join(CONFIG_PATH);
1523    let text = match std::fs::read_to_string(&path) {
1524        Ok(text) => text,
1525        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1526            if id.is_none() {
1527                return Ok(());
1528            }
1529            format!("schema_version = {SCHEMA_VERSION}\n")
1530        }
1531        Err(error) => return Err(error.into()),
1532    };
1533    let next = with_migration(&current_text(&text)?, id)?;
1534    write_checked(target, next.as_bytes())
1535}
1536
1537/// Change one landing parameter while preserving comments and table ordering.
1538///
1539/// # Errors
1540/// Refuses an invalid key, invalid resulting content, an unreadable file, or
1541/// a configuration path a symlink redirects; writes atomically.
1542pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
1543    let path = target.join(CONFIG_PATH);
1544    let text = std::fs::read_to_string(&path)?;
1545    let next = rewrite_text(&text, key, Some(value))?;
1546    write_checked(target, next.as_bytes())
1547}
1548
1549/// The text with `key` set to `value`, or removed where `value` is
1550/// `None`, every other byte kept. A schema 1 text migrates first.
1551fn rewrite_text(text: &str, key: &str, value: Option<toml_edit::Value>) -> Result<String, RkError> {
1552    rewrite_all(text, vec![(key, value)])
1553}
1554
1555/// The text with every named key set or removed in one document, every
1556/// other byte kept. A schema 1 text migrates first.
1557///
1558/// One document rather than one per key, because the answers are one
1559/// decision: writing `profile.release.mode = "none"` before removing the
1560/// driver it retires would make an intermediate text the reader rejects,
1561/// and an operator would have no command that performs the transition.
1562fn rewrite_all(
1563    text: &str,
1564    values: Vec<(&str, Option<toml_edit::Value>)>,
1565) -> Result<String, RkError> {
1566    for (key, _) in &values {
1567        if !PARAMETER_KEYS.contains(key) {
1568            return Err(invalid(format!("{key} is not a landing parameter")));
1569        }
1570    }
1571    parse(text)?;
1572    let text = current_text(text)?;
1573    let mut document = text
1574        .parse::<toml_edit::DocumentMut>()
1575        .map_err(|error| invalid(error.to_string()))?;
1576    let mut changed = false;
1577    // Only a table this call emptied may be pruned, so the names come from
1578    // the removals rather than from the whole key list.
1579    let mut emptied: Vec<&str> = Vec::new();
1580    for (key, value) in values {
1581        let removing = value.is_none();
1582        let applied = apply_key(&mut document, key, value)?;
1583        changed |= applied;
1584        if applied
1585            && removing
1586            && let Some(parent) = key.split('.').next()
1587        {
1588            emptied.push(parent);
1589        }
1590    }
1591    changed |= prune_empty_tables(&mut document, &emptied);
1592    // A value this writer rewrites keeps the comment the file already
1593    // carried, so the template's own sentence about a key outlives the
1594    // answer it describes: a target moving to local integration would read
1595    // `# F: invariant, contains all four rules` beside two of them. The
1596    // refresh replaces the template's comments alone and leaves every one
1597    // the operator wrote.
1598    changed |= migrate::refresh_comments(&mut document);
1599    if !changed {
1600        return Ok(text);
1601    }
1602    let next = document.to_string();
1603    parse(&next)?;
1604    Ok(next)
1605}
1606
1607/// Drop the named tables that are now empty, answering whether the
1608/// document changed.
1609///
1610/// `target-config:an-unanswered-key-is-absent-and-not-empty` asks the
1611/// writer to leave out a table every one of whose keys it omitted, and the
1612/// fresh render already does. This is the same rule on the update path,
1613/// which edits authored text instead: without it a target that drops its
1614/// forge keeps a bare `[project]` header, and the two writers disagree
1615/// about one resolved answer.
1616///
1617/// Only the named tables, because a table the operator authored empty is
1618/// theirs and this writer emptied nothing in it.
1619///
1620/// Every comment the removed header carried, standing above it or inline
1621/// beside it, moves to the next table, or to the end of the file where the
1622/// removed table was last. The header is this binary's; the comment may be
1623/// the operator's, and an upgrade that silently deleted one would be
1624/// losing authored text.
1625pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1626    let order: Vec<String> = document
1627        .as_table()
1628        .iter()
1629        .map(|(key, _)| key.to_owned())
1630        .collect();
1631    let mut changed = false;
1632    for (index, name) in order.iter().enumerate() {
1633        if !names.contains(&name.as_str())
1634            || !document
1635                .get(name)
1636                .and_then(toml_edit::Item::as_table)
1637                .is_some_and(toml_edit::Table::is_empty)
1638        {
1639            continue;
1640        }
1641        let carried = document
1642            .get(name)
1643            .and_then(toml_edit::Item::as_table)
1644            .and_then(|table| carried_comment(table.decor()));
1645        document.remove(name);
1646        changed = true;
1647        let Some(carried) = carried else { continue };
1648        // The next header the file actually prints: an implicit table
1649        // emits no header of its own, so its decor would take the comment
1650        // out of the rendered text with it.
1651        let next = order
1652            .iter()
1653            .skip(index + 1)
1654            .find(|name| {
1655                document
1656                    .get(name)
1657                    .and_then(toml_edit::Item::as_table)
1658                    .is_some_and(|table| !table.is_implicit())
1659            })
1660            .cloned();
1661        if let Some(next) = next
1662            && let Some(table) = document
1663                .get_mut(&next)
1664                .and_then(toml_edit::Item::as_table_mut)
1665        {
1666            let existing = table
1667                .decor()
1668                .prefix()
1669                .and_then(toml_edit::RawString::as_str)
1670                .unwrap_or_default()
1671                .trim_start_matches('\n')
1672                .to_owned();
1673            table
1674                .decor_mut()
1675                .set_prefix(format!("\n{carried}{existing}"));
1676        } else {
1677            let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1678            if !trailing.is_empty() && !trailing.ends_with('\n') {
1679                trailing.push('\n');
1680            }
1681            trailing.push_str(&carried);
1682            document.set_trailing(trailing);
1683        }
1684    }
1685    changed
1686}
1687
1688/// Every comment in a decor, one per line, or `None` where it carries
1689/// none.
1690///
1691/// An inline comment beside a header becomes a free-standing line, because
1692/// the header it sat beside is going and a comment needs a line of its own
1693/// to survive.
1694fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1695    let mut lines = String::new();
1696    for raw in [decor.prefix(), decor.suffix()] {
1697        let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1698            continue;
1699        };
1700        for line in text
1701            .lines()
1702            .map(str::trim)
1703            .filter(|line| line.starts_with('#'))
1704            .filter(|line| !is_template_comment(line))
1705        {
1706            lines.push_str(line);
1707            lines.push('\n');
1708        }
1709    }
1710    (!lines.is_empty()).then_some(lines)
1711}
1712
1713/// Whether the comment is the template's own rather than the operator's.
1714///
1715/// The template marks every comment it writes with the class of the key it
1716/// sits beside: `# P:` a landing parameter, `# N:` a free name, `# F:` an
1717/// invariant floor. A comment for a key that is going describes nothing
1718/// once the key is gone, so it goes too, while anything the operator wrote
1719/// is carried. `refresh_comment` owns the same three markers on the
1720/// migration path.
1721fn is_template_comment(line: &str) -> bool {
1722    let rest = line.trim_start_matches('#').trim_start();
1723    ["P:", "N:", "F:"]
1724        .iter()
1725        .any(|marker| rest.starts_with(marker))
1726}
1727
1728/// Put carried comment lines where the rendered file will still show
1729/// them: above the first header it prints, or at its end where it prints
1730/// none.
1731///
1732/// The last resort for text whose own domain is not rendered. A comment
1733/// with no home is still the operator's, and the end of the file is where
1734/// it survives.
1735pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1736    let first = document
1737        .as_table()
1738        .iter()
1739        .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1740        .map(|(name, _)| name.to_owned());
1741    if let Some(first) = first
1742        && let Some(table) = document
1743            .get_mut(&first)
1744            .and_then(toml_edit::Item::as_table_mut)
1745    {
1746        let existing = table
1747            .decor()
1748            .prefix()
1749            .and_then(toml_edit::RawString::as_str)
1750            .unwrap_or_default()
1751            .trim_start_matches('\n')
1752            .to_owned();
1753        table
1754            .decor_mut()
1755            .set_prefix(format!("\n{carried}{existing}"));
1756        return;
1757    }
1758    let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1759    if !trailing.is_empty() && !trailing.ends_with('\n') {
1760        trailing.push('\n');
1761    }
1762    trailing.push_str(carried);
1763    document.set_trailing(trailing);
1764}
1765
1766/// The operator's comments standing above `key`, or `None` where it
1767/// carries none.
1768///
1769/// Read before a move, so the comment travels with the value it describes
1770/// rather than staying beside a key that is gone.
1771pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1772    let (name, _) = table.get_key_value(key)?;
1773    carried_comment(name.leaf_decor())
1774}
1775
1776/// Put `carried` above `key`, keeping whatever decor it already has.
1777pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1778    let Some(mut name) = table.key_mut(key) else {
1779        return;
1780    };
1781    let decor = name.leaf_decor_mut();
1782    let existing = decor
1783        .prefix()
1784        .and_then(toml_edit::RawString::as_str)
1785        .unwrap_or_default()
1786        .trim_start_matches('\n')
1787        .to_owned();
1788    decor.set_prefix(format!("\n{carried}{existing}"));
1789}
1790
1791/// The nearest known name to `unknown`, for a refusal that helps.
1792#[must_use]
1793pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1794    known
1795        .iter()
1796        .min_by_key(|name| distance(unknown, name))
1797        .copied()
1798}
1799
1800/// The operator's comments standing on a table's own header, removed from
1801/// it.
1802pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1803    let carried = carried_comment(table.decor())?;
1804    table.decor_mut().set_prefix("\n");
1805    Some(carried)
1806}
1807
1808/// Remove `key` from `table`, answering the operator's comments it
1809/// carried.
1810///
1811/// A comment the operator wrote above or beside a key outlives the answer
1812/// it described: `project-profile:a-schema-one-configuration-migrates-in-place`
1813/// asks for every free-standing comment to survive, and an upgrade that
1814/// retires a key is the same promise on the other writer. The comments
1815/// move to the table's own header, where they read as a note on the domain
1816/// the key belonged to, and travel further with that header if the table
1817/// itself empties.
1818pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1819    let carried = table.get_key_value(key).and_then(|(name, item)| {
1820        let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1821        if let Some(value) = item.as_value()
1822            && let Some(more) = carried_comment(value.decor())
1823        {
1824            lines.push_str(&more);
1825        }
1826        (!lines.is_empty()).then_some(lines)
1827    });
1828    let removed = table.remove(key).is_some();
1829    if let Some(carried) = carried {
1830        let existing = table
1831            .decor()
1832            .prefix()
1833            .and_then(toml_edit::RawString::as_str)
1834            .unwrap_or_default()
1835            .trim_start_matches('\n')
1836            .to_owned();
1837        table
1838            .decor_mut()
1839            .set_prefix(format!("\n{carried}{existing}"));
1840    }
1841    removed
1842}
1843
1844/// Set or remove one key in an open document, answering whether the
1845/// document changed. No validation: the caller validates the whole.
1846fn apply_key(
1847    document: &mut toml_edit::DocumentMut,
1848    key: &str,
1849    value: Option<toml_edit::Value>,
1850) -> Result<bool, RkError> {
1851    let segments: Vec<&str> = key.split('.').collect();
1852    // Every key in `PARAMETER_KEYS` has at least one segment, so the split
1853    // answers; a key that did not would have refused above.
1854    let Some((last, parents)) = segments.split_last() else {
1855        return Err(invalid(format!("{key} names no key")));
1856    };
1857    let Some(mut value) = value else {
1858        let mut item = document.as_item_mut();
1859        for segment in parents {
1860            if item.get(segment).is_none() {
1861                return Ok(false);
1862            }
1863            item = &mut item[segment];
1864        }
1865        let removed = item
1866            .as_table_mut()
1867            .is_some_and(|table| take_comments(table, last));
1868        return Ok(removed);
1869    };
1870    let mut item = document.as_item_mut();
1871    for segment in parents {
1872        if item.get(segment).is_none() {
1873            let mut table = toml_edit::Table::new();
1874            table.set_implicit(true);
1875            item[segment] = toml_edit::Item::Table(table);
1876        }
1877        item = &mut item[segment];
1878    }
1879    if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1880        if same_value(old, &value) {
1881            return Ok(false);
1882        }
1883        *value.decor_mut() = old.decor().clone();
1884    } else if let Some(comment) = migrate::template_comment(&segments) {
1885        // A key this writer is adding rather than changing carries no
1886        // decor of the operator's, so it takes the template's own
1887        // comment. Without this a parameter that arrives in a later
1888        // release lands bare in every existing target's configuration,
1889        // beside keys that all state their class and their meaning.
1890        value.decor_mut().set_suffix(comment);
1891    }
1892    item[last] = toml_edit::Item::Value(value);
1893    Ok(true)
1894}
1895
1896/// Whether two values carry the same data however each is spelled: the
1897/// quote style, an escape, and the decor do not count, so an equal value
1898/// keeps the operator's own text.
1899fn same_value(old: &toml_edit::Value, new: &toml_edit::Value) -> bool {
1900    let parsed = |value: &toml_edit::Value| {
1901        format!("v = {}", value.to_string().trim()).parse::<toml::Table>()
1902    };
1903    matches!((parsed(old), parsed(new)), (Ok(old), Ok(new)) if old == new)
1904}
1905
1906/// Resolved landing input, including every key a preview would write.
1907#[derive(Debug, Clone, serde::Serialize)]
1908pub struct Plan {
1909    /// Added or updated configuration.
1910    pub action: &'static str,
1911    /// Keys whose configured answers differ from the record.
1912    pub changes: Vec<String>,
1913    /// The exact authored TOML the apply writes.
1914    pub content: String,
1915}
1916
1917impl Plan {
1918    /// Resolve the output without writing it; existing comments survive.
1919    ///
1920    /// # Errors
1921    /// Propagates unreadable or invalid configuration.
1922    pub fn new(
1923        target: &Path,
1924        params: &crate::landing::Params,
1925        existing: Option<&Config>,
1926        record: Option<&crate::landing::manifest::Manifest>,
1927    ) -> Result<Self, RkError> {
1928        let text = existing
1929            .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1930            .transpose()?;
1931        Self::compose(text.as_deref(), params, existing, record)
1932    }
1933
1934    /// Resolve the output from the existing text already read, so a
1935    /// planner that owns no filesystem can compose it from its
1936    /// observation; existing comments survive.
1937    ///
1938    /// # Errors
1939    /// Propagates invalid configuration.
1940    pub fn compose(
1941        text: Option<&str>,
1942        params: &crate::landing::Params,
1943        existing: Option<&Config>,
1944        record: Option<&crate::landing::manifest::Manifest>,
1945    ) -> Result<Self, RkError> {
1946        let mut resolved = existing.cloned().unwrap_or_default();
1947        resolved.schema_version = SCHEMA_VERSION;
1948        params.repo().clone_into(&mut resolved.project.repo);
1949        resolved.profile = Profile {
1950            technologies: Some(params.technologies().to_vec()),
1951            forge: Some(params.forge().unwrap_or_default().to_owned()),
1952            release: Release {
1953                mode: Some(params.release_mode()),
1954                driver: params.driver().map(str::to_owned),
1955                style: params.style(),
1956                line_prefix: params.profile().release.line_prefix.clone(),
1957                registry: params.profile().release.registry,
1958            },
1959        };
1960        resolved.git = Git {
1961            trunk: Some(params.trunk().to_owned()),
1962            checkout_mode: Some(params.checkout_mode()),
1963            integration: Some(params.integration()),
1964            merge_form: Some(params.merge_form()),
1965        };
1966        resolved.protection = with_merge_form(
1967            &protection_for(
1968                &resolved.protection,
1969                params.integration(),
1970                existing.is_none(),
1971            ),
1972            params.merge_form(),
1973        );
1974        resolved.capabilities = Capabilities {
1975            nix_packaging: Some(params.nix_packaging()),
1976            reporting_policy: Some(params.reporting_policy()),
1977            scorecard: Some(params.scorecard()),
1978            code_scanning: Some(
1979                params
1980                    .code_scanning()
1981                    .map_or("off", crate::landing::Provider::as_str)
1982                    .to_owned(),
1983            ),
1984            workflow_guards: Some(params.workflow_guards()),
1985        };
1986        // A release-less target with no reporting policy states no
1987        // security answer: the fresh file leaves the table out, and an
1988        // existing file keeps whatever it already says.
1989        if params.release_mode() != crate::profile::ReleaseMode::None || params.reporting_policy() {
1990            resolved.security.contact = Some(params.security_contact().to_owned());
1991            resolved.security.response = Some(params.security_response().to_owned());
1992            resolved.security.scope = Some(params.security_scope().to_owned());
1993        }
1994        params
1995            .required_check()
1996            .clone_into(&mut resolved.setup.required_check);
1997        params
1998            .required_workflow()
1999            .clone_into(&mut resolved.setup.required_workflow);
2000        // A file that states an open migration and nothing else is the one
2001        // the archive wrote ahead of the landing: the landing renders the
2002        // template a greenfield target takes and keeps the key, so closing
2003        // the migration leaves exactly the greenfield file.
2004        let bare = existing.is_some_and(Config::states_only_a_migration);
2005        let content = if let Some(text) = text.filter(|_| existing.is_some() && !bare) {
2006            rewrite_all(text, parameter_values(&resolved))?
2007        } else {
2008            let rendered =
2009                String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?;
2010            with_migration(&rendered, resolved.migration.as_deref())?
2011        };
2012        parse(&content)?;
2013        Ok(Self {
2014            action: if existing.is_some() {
2015                "updated"
2016            } else {
2017                "added"
2018            },
2019            changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
2020            content,
2021        })
2022    }
2023
2024    /// Write the prepared configuration before the landing record.
2025    ///
2026    /// # Errors
2027    /// Refuses a configuration path a symlink redirects, and propagates an
2028    /// atomic write failure.
2029    pub fn apply(&self, target: &Path) -> Result<(), RkError> {
2030        write_checked(target, self.content.as_bytes())
2031    }
2032}
2033
2034/// Every class P key with its value, `None` for a key the answers omit.
2035#[allow(
2036    clippy::too_many_lines,
2037    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"
2038)]
2039fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
2040    let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
2041        "project.repo",
2042        (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
2043    )];
2044    if let Some(list) = &config.profile.technologies {
2045        values.push(("profile.technologies", Some(array(list))));
2046    }
2047    if let Some(forge) = config.profile.forge.clone() {
2048        values.push(("profile.forge", Some(forge.into())));
2049    }
2050    if let Some(mode) = config.profile.release.mode {
2051        values.push(("profile.release.mode", Some(mode.as_str().into())));
2052        values.push((
2053            "profile.release.driver",
2054            config
2055                .profile
2056                .release
2057                .driver
2058                .clone()
2059                .map(toml_edit::Value::from),
2060        ));
2061        values.push((
2062            "profile.release.style",
2063            config
2064                .profile
2065                .release
2066                .style
2067                .map(|style| style.as_str().into()),
2068        ));
2069        values.push((
2070            "profile.release.line_prefix",
2071            config
2072                .profile
2073                .release
2074                .line_prefix
2075                .clone()
2076                .map(toml_edit::Value::from),
2077        ));
2078        values.push((
2079            "profile.release.registry",
2080            config
2081                .profile
2082                .release
2083                .registry
2084                .map(|answer| answer.as_str().into()),
2085        ));
2086    }
2087    if let Some(trunk) = config.git.trunk.clone() {
2088        values.push(("git.trunk", Some(trunk.into())));
2089    }
2090    if let Some(mode) = config.git.checkout_mode {
2091        values.push(("git.checkout_mode", Some(mode.as_str().into())));
2092    }
2093    if let Some(mode) = config.git.integration {
2094        values.push(("git.integration", Some(mode.as_str().into())));
2095        // The policy tuple the authority decides travels with it: a write-back
2096        // that moved the mode alone would leave a configuration the
2097        // floor table refuses on the next read.
2098        let mut bypass = toml_edit::Array::new();
2099        for actor in &config.protection.bypass_actors {
2100            bypass.push(actor.as_str());
2101        }
2102        values.push((
2103            "protection.bypass_actors",
2104            Some(toml_edit::Value::Array(bypass)),
2105        ));
2106        let mut rules = toml_edit::Array::new();
2107        for rule in &config.protection.owned_trunk_rules {
2108            rules.push(rule.as_str());
2109        }
2110        values.push((
2111            "protection.owned_trunk_rules",
2112            Some(toml_edit::Value::Array(rules)),
2113        ));
2114        values.push((
2115            "protection.gitlab.push_access_level",
2116            Some(config.protection.gitlab.push_access_level.into()),
2117        ));
2118    }
2119    if let Some(form) = config.git.merge_form {
2120        values.push(("git.merge_form", Some(form.as_str().into())));
2121        // The merge values the form decides travel with it, for the reason
2122        // the authority's tuple does: the floor table judges them by the
2123        // form, so a write-back that moved the form alone would leave a
2124        // configuration that refuses itself on the next read.
2125        let mut methods = toml_edit::Array::new();
2126        for method in &config.protection.allowed_merge_methods {
2127            methods.push(method.as_str());
2128        }
2129        values.push((
2130            "protection.allowed_merge_methods",
2131            Some(toml_edit::Value::Array(methods)),
2132        ));
2133        values.push((
2134            "protection.gitlab.squash_option",
2135            Some(config.protection.gitlab.squash_option.clone().into()),
2136        ));
2137    }
2138    if let Some(value) = config.capabilities.nix_packaging {
2139        values.push(("capabilities.nix_packaging", Some(value.into())));
2140    }
2141    if let Some(value) = config.capabilities.reporting_policy {
2142        values.push(("capabilities.reporting_policy", Some(value.into())));
2143    }
2144    if let Some(value) = config.capabilities.scorecard {
2145        values.push(("capabilities.scorecard", Some(value.into())));
2146    }
2147    if let Some(value) = config.capabilities.code_scanning.clone() {
2148        values.push(("capabilities.code_scanning", Some(value.into())));
2149    }
2150    if let Some(value) = config.capabilities.workflow_guards {
2151        values.push(("capabilities.workflow_guards", Some(value.into())));
2152    }
2153    // An empty contact is an answer, not an absence: it resets the landed
2154    // policy to the forge's own prose, so it projects like any other value.
2155    if let Some(value) = config.security.contact.clone() {
2156        values.push(("security.contact", Some(value.into())));
2157    }
2158    if let Some(value) = config.security.response.clone() {
2159        values.push(("security.response", Some(value.into())));
2160    }
2161    if let Some(value) = config.security.scope.clone() {
2162        values.push(("security.scope", Some(value.into())));
2163    }
2164    // The gate's two answers project like any other class P string. An
2165    // empty answer is an answer: it says this target renders no standing
2166    // release gate, which is true of every target outside GitHub's local
2167    // trunk release shape.
2168    values.push((
2169        "setup.required_check",
2170        Some(config.setup.required_check.clone().into()),
2171    ));
2172    values.push((
2173        "setup.required_workflow",
2174        Some(config.setup.required_workflow.clone().into()),
2175    ));
2176    values
2177}
2178
2179/// Only explicit class P answers can be pending; comparisons still use the record.
2180#[must_use]
2181pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
2182    let recorded = {
2183        let params = crate::landing::Params::from_record(record);
2184        Plan::compose(None, &params, None, None)
2185            .ok()
2186            .and_then(|plan| parse(&plan.content).ok())
2187    };
2188    let Some(recorded) = recorded else {
2189        return Vec::new();
2190    };
2191    let render = |value: &Option<toml_edit::Value>| {
2192        value.as_ref().map_or_else(
2193            || "<absent>".to_owned(),
2194            |value| value.to_string().trim().to_owned(),
2195        )
2196    };
2197    let baseline: Vec<(&str, String)> = parameter_values(&recorded)
2198        .iter()
2199        .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2200        .map(|(key, value)| (*key, render(value)))
2201        .collect();
2202    let mut keys: Vec<String> = parameter_values(config)
2203        .into_iter()
2204        .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
2205        .map(|(key, value)| (key, render(&value)))
2206        .filter(|(key, value)| {
2207            baseline
2208                .iter()
2209                .any(|(other, old)| key == other && value != old)
2210        })
2211        .map(|(key, _)| key.to_owned())
2212        .collect();
2213    // The development environment is a table the operator writes by hand
2214    // and the landing never rewrites, so it is pending wherever the file
2215    // states an answer the record does not carry.
2216    if let Some(answer) = &config.dev_environment
2217        && answer.clone().answered() != record.parameters.dev_environment
2218    {
2219        keys.push("dev_environment".to_owned());
2220    }
2221    keys
2222}
2223
2224/// The compiled protection floors a locally integrated trunk carries.
2225///
2226/// The ordinary defaults describe forge integration, because that is the
2227/// shape this convention had before the authority became an axis. A
2228/// locally integrated trunk keeps the full GitHub rule set and grants the
2229/// repository-administrator role the bypass its deliberate push needs. On
2230/// GitLab it takes the narrowest access level that still admits that push.
2231#[must_use]
2232pub(crate) fn local_protection() -> Protection {
2233    /// The narrowest GitLab access level that still admits a push.
2234    const MAINTAINER: i64 = 40;
2235    Protection {
2236        bypass_actors: vec![LOCAL_GITHUB_BYPASS.into()],
2237        gitlab: Gitlab {
2238            push_access_level: MAINTAINER,
2239            ..Gitlab::default()
2240        },
2241        ..Protection::default()
2242    }
2243}
2244
2245/// The merge method a forge allows on the trunk under one merge form, and
2246/// the GitLab squash setting that goes with it.
2247///
2248/// The floors hold both keys to exactly these values, so the form decides
2249/// them whole: there is no stricter answer to keep, and a landing that
2250/// resolves a form writes its values whatever the file held.
2251#[must_use]
2252pub const fn merge_values(form: MergeForm) -> (&'static str, &'static str) {
2253    match form {
2254        MergeForm::Squash => ("squash", "always"),
2255        MergeForm::Rebase => ("rebase", "never"),
2256    }
2257}
2258
2259/// The policy with the merge values `form` names.
2260///
2261/// The squash message sources stay as stated: they are the forge's
2262/// squash settings, which no rebase merge reads, and `squash` floors them.
2263#[must_use]
2264pub fn with_merge_form(policy: &Protection, form: MergeForm) -> Protection {
2265    let (method, squash_option) = merge_values(form);
2266    let mut next = policy.clone();
2267    next.allowed_merge_methods = vec![method.to_owned()];
2268    squash_option.clone_into(&mut next.gitlab.squash_option);
2269    next
2270}
2271
2272/// Whether this is the compiled local policy from before release-request
2273/// freshness moved back into the trunk ruleset.
2274#[must_use]
2275pub(crate) fn legacy_local_protection(policy: &Protection) -> bool {
2276    policy.bypass_actors.is_empty()
2277        && policy.owned_trunk_rules == ["deletion", "non_fast_forward"]
2278        && policy.gitlab.push_access_level >= 40
2279}
2280
2281/// The protection values a landing writes, for one integration authority.
2282///
2283/// Exactly three keys differ between the authorities, and they are the
2284/// tuple this looks at: the GitHub bypass actors, the owned trunk rules,
2285/// and the GitLab push access level. A target whose tuple matches one
2286/// authority's compiled defaults never stated them; it took them, so a
2287/// landing that resolves the other authority writes that authority's tuple
2288/// instead, and a fresh landing
2289/// writes its own. A target whose tuple matches neither is one an operator
2290/// narrowed or widened, and it keeps every value it stated: the floor
2291/// table already judged it under the same mode, and an operator who
2292/// changed a protection meant it.
2293///
2294/// The tuple alone, never the whole policy: every other key here is a name
2295/// or a review policy the authority does not decide, and a target that
2296/// renamed its ruleset would otherwise read as having stated the pair.
2297///
2298/// This is what makes the committed configuration the one source: the
2299/// floors judge these values, the setup installs them, and its check
2300/// reads them back, so a local-integration target is never handed a trunk
2301/// its own integrations cannot push.
2302#[must_use]
2303fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
2304    let forge = Protection::default();
2305    let local = local_protection();
2306    let tuple = |policy: &Protection| {
2307        (
2308            policy.bypass_actors.clone(),
2309            policy.owned_trunk_rules.clone(),
2310            policy.gitlab.push_access_level,
2311        )
2312    };
2313    let taken = fresh
2314        || tuple(held) == tuple(&forge)
2315        || tuple(held) == tuple(&local)
2316        || legacy_local_protection(held);
2317    if !taken {
2318        return held.clone();
2319    }
2320    let mut next = held.clone();
2321    let source = match integration {
2322        Integration::Forge => forge,
2323        Integration::Local => local,
2324    };
2325    next.bypass_actors = source.bypass_actors;
2326    next.owned_trunk_rules = source.owned_trunk_rules;
2327    next.gitlab.push_access_level = source.gitlab.push_access_level;
2328    next
2329}
2330
2331/// The trunk accessor for callers without a setup context.
2332///
2333/// # Errors
2334/// Propagates invalid configuration and I/O failures.
2335pub fn trunk_of(target: &Path) -> Result<String, RkError> {
2336    Ok(load(target)?
2337        .and_then(|config| config.git.trunk)
2338        .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
2339}
2340
2341/// The release-line prefix for callers without a setup context.
2342///
2343/// # Errors
2344/// Propagates invalid configuration and I/O failures.
2345pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
2346    Ok(load(target)?
2347        .and_then(|config| config.profile.release.line_prefix)
2348        .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
2349}
2350
2351#[cfg(test)]
2352mod tests {
2353    use super::{CONFIG_PATH, Config, load, parse, rewrite_key, set_migration, trunk_of, write};
2354    use crate::landing::{CheckoutMode, Integration, MergeForm, Style};
2355    use crate::profile::ReleaseMode;
2356
2357    /// The resolved defaults a landing writes for an automatic rust
2358    /// release on GitHub.
2359    fn resolved_defaults() -> Config {
2360        Config {
2361            project: super::Project {
2362                repo: "acme/widget".into(),
2363            },
2364            profile: super::Profile {
2365                technologies: Some(vec!["rust".into()]),
2366                forge: Some("github".into()),
2367                release: super::Release {
2368                    mode: Some(ReleaseMode::Automatic),
2369                    driver: Some("rust".into()),
2370                    style: Some(Style::Trunk),
2371                    line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
2372                    registry: None,
2373                },
2374            },
2375            git: super::Git {
2376                trunk: Some(super::TRUNK_DEFAULT.into()),
2377                checkout_mode: Some(CheckoutMode::LinkedWorktree),
2378                integration: Some(Integration::Local),
2379                merge_form: Some(MergeForm::Squash),
2380            },
2381            capabilities: super::Capabilities {
2382                nix_packaging: Some(false),
2383                reporting_policy: Some(true),
2384                scorecard: Some(false),
2385                code_scanning: Some("off".to_owned()),
2386                workflow_guards: Some(false),
2387            },
2388            // Writing states both security answers, so a reader sees the
2389            // policy the target landed rather than an implied one.
2390            security: super::Security {
2391                contact: Some(String::new()),
2392                response: Some(super::RESPONSE_DEFAULT.into()),
2393                scope: Some(String::new()),
2394                ..super::Security::default()
2395            },
2396            // Writing resolves the derived ruleset name, so the file states
2397            // the name the setup installs rather than leaving it implied.
2398            protection: super::Protection {
2399                trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
2400                safety_ruleset: Some(format!("{}-safety", super::TRUNK_DEFAULT)),
2401                ..super::local_protection()
2402            },
2403            ..Config::default()
2404        }
2405    }
2406
2407    /// A symlinked configuration directory or file would carry a write out
2408    /// of the target, so every writer refuses it and writes nothing.
2409    #[test]
2410    fn a_symlinked_configuration_path_refuses_every_writer() {
2411        let outside = tempfile::tempdir().expect("an outside directory exists");
2412        let text = "schema_version = 2\n";
2413        std::fs::write(outside.path().join("config.toml"), text).expect("writes");
2414        let linked_dir = tempfile::tempdir().expect("a tempdir");
2415        std::os::unix::fs::symlink(outside.path(), linked_dir.path().join(".release-kit"))
2416            .expect("links the directory");
2417        let linked_file = tempfile::tempdir().expect("a tempdir");
2418        std::fs::create_dir_all(linked_file.path().join(".release-kit")).expect("mkdir");
2419        std::os::unix::fs::symlink(
2420            outside.path().join("config.toml"),
2421            linked_file.path().join(CONFIG_PATH),
2422        )
2423        .expect("links the file");
2424        for dir in [&linked_dir, &linked_file] {
2425            let target = dir.path();
2426            let refusals = [
2427                write(target, &resolved_defaults()).expect_err("write refuses"),
2428                rewrite_key(target, "git.trunk", "main".into()).expect_err("rewrite refuses"),
2429                set_migration(target, Some("20261009T140205Z")).expect_err("the key refuses"),
2430            ];
2431            for error in refusals {
2432                assert_eq!(error.exit_code(), 73, "{error}");
2433                assert!(error.to_string().contains("symlink"), "{error}");
2434            }
2435        }
2436        assert_eq!(
2437            std::fs::read_to_string(outside.path().join("config.toml")).expect("reads"),
2438            text
2439        );
2440    }
2441
2442    #[test]
2443    fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
2444        let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
2445        let explicit = parse(
2446            "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",
2447        )
2448        .expect("explicit defaults parse");
2449        assert_eq!(omitted.git, super::Git::default());
2450        assert_eq!(omitted.capabilities, super::Capabilities::default());
2451        assert_eq!(
2452            explicit.git.checkout_mode,
2453            Some(CheckoutMode::LinkedWorktree)
2454        );
2455        assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
2456        assert_eq!(explicit.capabilities.nix_packaging, Some(false));
2457        assert_eq!(explicit.capabilities.reporting_policy, Some(true));
2458        assert_eq!(explicit.capabilities.scorecard, Some(false));
2459        assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
2460        assert_ne!(omitted, explicit);
2461        // The older spellings of the checkout mode still read.
2462        let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
2463            .expect("the older spelling reads");
2464        assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
2465    }
2466
2467    /// A configuration written by 0.3.13 carries `installation_id`, which
2468    /// this version reads and ignores. Refusing it would strand every
2469    /// target that release landed.
2470    #[test]
2471    fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
2472        let dir = tempfile::tempdir().expect("a tempdir");
2473        std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
2474        std::fs::write(
2475            dir.path().join(CONFIG_PATH),
2476            "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
2477        )
2478        .expect("the config writes");
2479        let held = load(dir.path())
2480            .expect("the config reads")
2481            .expect("it is present");
2482        assert_eq!(held.setup.bot.app_id, "123");
2483        assert_eq!(
2484            held.setup.bot.installation_id,
2485            Some(0),
2486            "the key parses; nothing reads it"
2487        );
2488    }
2489
2490    /// SATISFIES project-profile:a-schema-one-configuration-migrates-in-place
2491    #[test]
2492    fn a_schema_1_config_migrates_into_its_domains() {
2493        let held = parse(
2494            "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",
2495        )
2496        .expect("a schema 1 file reads");
2497        assert_eq!(held.schema_version, 2);
2498        assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
2499        assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
2500        assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
2501        assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
2502        assert_eq!(held.profile.release.style, Some(Style::Lines));
2503        assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
2504        assert_eq!(held.git.trunk.as_deref(), Some("main"));
2505        assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
2506        assert_eq!(held.capabilities.nix_packaging, Some(false));
2507        assert_eq!(held.capabilities.reporting_policy, Some(true));
2508    }
2509
2510    /// SATISFIES project-profile:release-intent-has-three-modes
2511    #[test]
2512    fn every_invalid_release_state_names_its_key() {
2513        for (text, key) in [
2514            (
2515                "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
2516                "profile.release.style",
2517            ),
2518            (
2519                "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
2520                "profile.release.driver",
2521            ),
2522            (
2523                "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
2524                "profile.release.line_prefix",
2525            ),
2526            (
2527                "[profile]\ntechnologies = ['rust', 'rust']\n",
2528                "profile.technologies",
2529            ),
2530            (
2531                "[profile]\ntechnologies = ['Rust!']\n",
2532                "profile.technologies",
2533            ),
2534            ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
2535            (
2536                "[profile.release]\nmode = 'manual'\n",
2537                "profile.release.mode",
2538            ),
2539        ] {
2540            let error = parse(&format!("schema_version = 2\n{text}"))
2541                .expect_err("an invalid release state refuses")
2542                .to_string();
2543            assert!(error.contains(key), "{key}: {error}");
2544        }
2545    }
2546
2547    /// A parameter that arrives in a later release lands beside the keys
2548    /// that were already there, stating its class and its meaning the way
2549    /// they do, rather than bare.
2550    #[test]
2551    fn a_newly_written_key_takes_the_templates_comment() {
2552        let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
2553        let next = super::rewrite_text(
2554            text,
2555            "git.integration",
2556            Some(toml_edit::Value::from("forge")),
2557        )
2558        .expect("the key writes");
2559        assert!(
2560            next.contains("integration = \"forge\" # P: local or forge"),
2561            "{next}"
2562        );
2563        // A key the operator already commented keeps their words.
2564        let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
2565            .expect("the key writes");
2566        assert!(next.contains("trunk = \"master\" # mine"), "{next}");
2567    }
2568
2569    #[test]
2570    fn the_landed_config_template_round_trips() {
2571        let dir = tempfile::tempdir().expect("a target exists");
2572        let mut config = Config::default();
2573        config.project.repo = "acme/nested/widget".into();
2574        config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
2575        config.profile.forge = Some("gitlab".into());
2576        config.profile.release = super::Release {
2577            mode: Some(ReleaseMode::Automatic),
2578            driver: Some("bash".into()),
2579            style: Some(Style::Lines),
2580            line_prefix: Some("stable/".into()),
2581            registry: None,
2582        };
2583        config.git.trunk = Some("main".into());
2584        config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
2585        config.git.integration = Some(Integration::Forge);
2586        config.git.merge_form = Some(MergeForm::Rebase);
2587        config.protection = super::with_merge_form(&config.protection, MergeForm::Rebase);
2588        config.capabilities.nix_packaging = Some(true);
2589        config.capabilities.reporting_policy = Some(false);
2590        config.capabilities.scorecard = Some(true);
2591        config.capabilities.code_scanning = Some("semgrep".to_owned());
2592        config.capabilities.workflow_guards = Some(true);
2593        // The escaping subject moved to the one unrestricted string in this
2594        // table: `contact` is now a class P value the reader holds to a
2595        // single control-free line, so it can carry neither.
2596        config.security.advisories =
2597            "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
2598        config.security.contact = Some("security team, room 3 \"the vault\"".into());
2599        config.security.response = Some("14 business days".into());
2600        // A scope is one paragraph, so it may break its lines, and a quote
2601        // or a token name inside it survives the round trip.
2602        config.security.scope =
2603            Some("Wraps the \"claude\" CLI; report it upstream.\nRK_CONFIG_SECURITY_SCOPE stays literal.".into());
2604        config.setup.required_check = "build / test".into();
2605        config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
2606        config.setup.release_lines = true;
2607        config.setup.excluded_steps = [
2608            (
2609                "package-check".to_owned(),
2610                "nothing is published".to_owned(),
2611            ),
2612            (
2613                "protect-trunk".to_owned(),
2614                "this project merges \"locally\"".to_owned(),
2615            ),
2616        ]
2617        .into_iter()
2618        .collect();
2619        config.setup.bot.app_id = "123".into();
2620        config.protection.trunk_ruleset = Some("primary".into());
2621        config.protection.safety_ruleset = Some("guardrail".into());
2622        config.protection.tag_ruleset = "versions".into();
2623        config.protection.lines_ruleset = "maintenance".into();
2624        config.protection.title_check = "intent".into();
2625        config.protection.tag_pattern = "refs/tags/*".into();
2626        config
2627            .protection
2628            .owned_trunk_rules
2629            .push("required_signatures".into());
2630        config.protection.required_approving_review_count = 2;
2631        config.protection.dismiss_stale_reviews_on_push = true;
2632        config.protection.require_code_owner_review = true;
2633        config.protection.require_last_push_approval = true;
2634        config.protection.gitlab.squash_commit_template =
2635            "%{title}\n\nContext: %{description}".into();
2636        config.protection.gitlab.merge_access_level = 40;
2637        // A release-less profile with no forge: the automatic-only keys
2638        // and the repository are absent from the written file.
2639        let mut release_less = resolved_defaults();
2640        release_less.project.repo = String::new();
2641        release_less.profile.technologies = Some(Vec::new());
2642        release_less.profile.forge = Some(String::new());
2643        release_less.profile.release = super::Release {
2644            mode: Some(ReleaseMode::None),
2645            driver: None,
2646            style: None,
2647            line_prefix: None,
2648            registry: None,
2649        };
2650        release_less.capabilities.reporting_policy = Some(false);
2651        // A release-less target with no reporting policy states no
2652        // security answer, so the file carries no `[security]` to read back.
2653        release_less.security.contact = None;
2654        release_less.security.response = None;
2655        release_less.security.scope = None;
2656        for expected in [resolved_defaults(), config, release_less] {
2657            write(dir.path(), &expected).expect("the template renders");
2658            assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
2659            let text =
2660                std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2661            assert!(text.contains("# P: every technology"));
2662            assert!(text.contains("# F: invariant"));
2663        }
2664        let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
2665        assert!(
2666            !text.contains("style ="),
2667            "a none release writes no style: {text}"
2668        );
2669        assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
2670    }
2671
2672    #[test]
2673    fn a_config_with_an_unknown_key_refuses_by_name() {
2674        for (table, typo, nearest) in [
2675            ("", "schemax_version", "schema_version"),
2676            ("git", "trunkx", "trunk"),
2677            ("profile.release", "stile", "style"),
2678            ("capabilities", "nix_packagingg", "nix_packaging"),
2679            ("security", "contactx", "contact"),
2680            ("setup", "required_checkx", "required_check"),
2681            ("setup.bot", "app_i", "app_id"),
2682            ("protection", "trunk_rulesett", "trunk_ruleset"),
2683            (
2684                "protection.github",
2685                "squash_body_sourcex",
2686                "squash_body_source",
2687            ),
2688            ("protection.gitlab", "squash_optionx", "squash_option"),
2689        ] {
2690            let header = if table.is_empty() {
2691                String::new()
2692            } else {
2693                format!("[{table}]\n")
2694            };
2695            let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
2696            let error = parse(&text).expect_err("unknown keys refuse").to_string();
2697            for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
2698                assert!(error.contains(expected), "{error}");
2699            }
2700        }
2701    }
2702
2703    /// An exclusion removes a step from scope, so a typo in one would
2704    /// silently keep judging a step the target does not run, and a
2705    /// reasonless one would leave a report nobody can audit.
2706    #[test]
2707    fn an_exclusion_names_a_real_step_and_states_why() {
2708        for (text, expected) in [
2709            (
2710                "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
2711                vec!["protect-trunkk", "nearest known step: protect-trunk"],
2712            ),
2713            (
2714                "[setup.excluded_steps]\nprotect-trunk = '  '\n",
2715                vec!["protect-trunk", "no reason"],
2716            ),
2717        ] {
2718            let error = parse(&format!("schema_version = 2\n{text}"))
2719                .expect_err("the exclusion refuses")
2720                .to_string();
2721            for want in expected {
2722                assert!(error.contains(want), "{error}");
2723            }
2724        }
2725        let held = parse(
2726            "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
2727        )
2728        .expect("a named step with a reason parses");
2729        assert_eq!(
2730            held.setup
2731                .excluded_steps
2732                .get("protect-trunk")
2733                .map(String::as_str),
2734            Some("we merge locally")
2735        );
2736    }
2737
2738    /// An exclusion narrows what the setup judges. It never weakens the
2739    /// method's policy, so the floors bind a target that runs a subset
2740    /// exactly as they bind one that runs every step.
2741    #[test]
2742    fn an_exclusion_does_not_lift_a_floor() {
2743        let error = parse(
2744            "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
2745        )
2746        .expect_err("the floor binds an excluded step's keys too")
2747        .to_string();
2748        assert!(
2749            error.contains("protection.allowed_merge_methods"),
2750            "{error}"
2751        );
2752    }
2753
2754    #[test]
2755    fn a_config_at_an_unknown_schema_refuses() {
2756        for text in ["schema_version = 999", "schema_version = '2'", ""] {
2757            let error = parse(text)
2758                .expect_err("a schema must be declared and known")
2759                .to_string();
2760            assert!(
2761                error.contains(CONFIG_PATH) && error.contains("schema_version"),
2762                "{error}"
2763            );
2764        }
2765    }
2766
2767    #[test]
2768    fn an_unparsable_config_refuses_naming_the_position() {
2769        let error = parse("schema_version = 2\n[project\n")
2770            .expect_err("bad TOML refuses")
2771            .to_string();
2772        for expected in [CONFIG_PATH, "line 2", "column"] {
2773            assert!(error.contains(expected), "{error}");
2774        }
2775    }
2776
2777    #[test]
2778    fn an_absent_config_reads_as_none() {
2779        let dir = tempfile::tempdir().expect("a target exists");
2780        assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2781        assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2782    }
2783
2784    #[test]
2785    fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2786        let dir = tempfile::tempdir().expect("a target exists");
2787        std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2788        std::fs::write(
2789            dir.path().join(CONFIG_PATH),
2790            "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2791        )
2792        .expect("a config exists");
2793        let error =
2794            trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2795        assert_eq!(error.exit_code(), 73);
2796        assert!(
2797            error
2798                .to_string()
2799                .contains("protection.strict_required_status_checks")
2800        );
2801    }
2802
2803    /// An equal value spelled another way is no change: the quote style
2804    /// and an escape stay the operator's.
2805    #[test]
2806    fn an_equal_value_keeps_its_spelling() {
2807        let original = "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'tr\\u0075nk' # ours\n";
2808        let original = original.replace("'tr\\u0075nk'", "\"tr\\u0075nk\"");
2809        let text = super::rewrite_text(&original, "profile.release.style", Some("trunk".into()))
2810            .expect("the style rewrites");
2811        assert_eq!(text, original, "an equal value is rewritten");
2812        let quoted =
2813            "schema_version = 2\n\n[profile.release]\nmode = 'automatic'\nstyle = 'trunk' # ours\n";
2814        let text = super::rewrite_text(quoted, "profile.release.style", Some("trunk".into()))
2815            .expect("the style rewrites");
2816        assert_eq!(
2817            text, quoted,
2818            "a literal string equal to the value is rewritten"
2819        );
2820    }
2821
2822    #[test]
2823    fn rewrite_key_preserves_comments() {
2824        let dir = tempfile::tempdir().expect("a target exists");
2825        std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2826        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";
2827        let path = dir.path().join(CONFIG_PATH);
2828        std::fs::write(&path, original).expect("a config exists");
2829        rewrite_key(dir.path(), "profile.release.style", "lines".into())
2830            .expect("the style writes back");
2831        let text = std::fs::read_to_string(&path).expect("the text reads");
2832        assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2833        assert_eq!(
2834            load(dir.path())
2835                .expect("the config reads")
2836                .expect("present")
2837                .profile
2838                .release
2839                .style,
2840            Some(Style::Lines)
2841        );
2842        rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2843            .expect("an omitted table can be added");
2844        assert_eq!(
2845            load(dir.path())
2846                .expect("reads")
2847                .expect("present")
2848                .project
2849                .repo,
2850            "acme/widget"
2851        );
2852        rewrite_key(
2853            dir.path(),
2854            "security.contact",
2855            "security@acme.example".into(),
2856        )
2857        .expect("the contact is a landing parameter");
2858        rewrite_key(dir.path(), "security.response", "14 days".into())
2859            .expect("the response is a landing parameter");
2860        let held = load(dir.path()).expect("reads").expect("present");
2861        assert_eq!(
2862            held.security.contact.as_deref(),
2863            Some("security@acme.example")
2864        );
2865        assert_eq!(held.security.response.as_deref(), Some("14 days"));
2866        let text = std::fs::read_to_string(&path).expect("the text reads");
2867        assert!(text.contains("# keep me"), "the comment survives: {text}");
2868        let before = std::fs::read(&path).expect("the bytes read");
2869        for (key, value) in [
2870            ("security.advisories", "acme/private"),
2871            ("security.response", "90d"),
2872            ("profile.release.style", "unknown"),
2873        ] {
2874            assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2875            assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2876        }
2877    }
2878
2879    /// The two security answers are one line and one narrow grammar,
2880    /// because both land verbatim in a public policy.
2881    #[test]
2882    fn the_security_answers_are_held_to_their_grammar() {
2883        for value in ["team@acme.example", "  https://acme.example/report  ", ""] {
2884            super::canonical_contact(value).expect("a control-free line is a contact");
2885        }
2886        for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2887            let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2888            assert!(refusal.contains("security.contact"), "{refusal}");
2889        }
2890        assert_eq!(
2891            super::canonical_contact("  team@acme.example  "),
2892            Ok("team@acme.example".to_owned()),
2893            "surrounding whitespace is trimmed"
2894        );
2895        for value in [
2896            "best-effort",
2897            "1 day",
2898            "2 days",
2899            "14 days",
2900            "1 business day",
2901            "14 business days",
2902        ] {
2903            assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2904        }
2905        assert_eq!(
2906            super::canonical_response(""),
2907            Ok(super::RESPONSE_DEFAULT.to_owned()),
2908            "an empty answer reads as the compiled default"
2909        );
2910        for value in [
2911            "0 days",
2912            "1 days",
2913            "2 day",
2914            "+2 days",
2915            "02 days",
2916            "4294967296 days",
2917            "90d",
2918            "two days",
2919            "we answer quickly",
2920            "2 weeks",
2921        ] {
2922            let refusal =
2923                super::canonical_response(value).expect_err("an unstateable window refuses");
2924            assert!(refusal.contains("security.response"), "{value}: {refusal}");
2925            assert!(refusal.contains("business days"), "{value}: {refusal}");
2926        }
2927        let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2928            .expect_err("the reader refuses it too")
2929            .to_string();
2930        assert!(refusal.contains("security.response"), "{refusal}");
2931    }
2932
2933    /// The scope is prose: one paragraph, which may break its lines, held
2934    /// only against what would break the policy it renders into.
2935    #[test]
2936    fn the_security_scope_is_one_paragraph() {
2937        for value in [
2938            "",
2939            "This wraps the claude CLI. Report a flaw in it upstream.",
2940            "One line,\nand a second line of the same paragraph.",
2941            "A #hashtag mid-line and a <comment> stay prose.",
2942            "A dash - and an = sign mid-line,\nand a line ending in --- stay prose.",
2943        ] {
2944            assert_eq!(super::canonical_scope(value), Ok(value.to_owned()));
2945        }
2946        assert_eq!(
2947            super::canonical_scope("  wrapped  \n"),
2948            Ok("wrapped".to_owned()),
2949            "surrounding whitespace is trimmed"
2950        );
2951        for (value, why) in [
2952            ("one\n\ntwo", "blank line"),
2953            ("one\n   \ntwo", "blank line"),
2954            ("one\r\ntwo", "control character"),
2955            ("one\ttwo", "control character"),
2956            ("# Out of scope", "heading"),
2957            ("intro\n  ## Out of scope", "heading"),
2958            ("Out of scope\n===", "underlines a Markdown heading"),
2959            ("Out of scope\n  ---  ", "underlines a Markdown heading"),
2960            ("see <!-- here", "comment delimiter"),
2961            ("here --> there", "comment delimiter"),
2962        ] {
2963            let refusal =
2964                super::canonical_scope(value).expect_err("a policy-breaking scope refuses");
2965            assert!(refusal.contains("security.scope"), "{value:?}: {refusal}");
2966            assert!(refusal.contains(why), "{value:?}: {refusal}");
2967        }
2968    }
2969
2970    /// SATISFIES target-config:an-open-migration-is-one-top-level-key
2971    /// The key names a workspace below the state root, so a value that is
2972    /// no identifier refuses before any verb joins it to a path.
2973    #[test]
2974    fn a_migration_key_that_is_no_identifier_refuses() {
2975        for id in ["20261009T140205Z", "20261009T140205Z-2"] {
2976            let config = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2977                .expect("an identifier parses");
2978            assert_eq!(config.migration.as_deref(), Some(id));
2979        }
2980        for id in [
2981            "../../elsewhere",
2982            "20261009T140205Z-1",
2983            "",
2984            "20261009T140205\u{e9}",
2985            "\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}\u{e9}",
2986        ] {
2987            let refusal = parse(&format!("schema_version = 2\nmigration = '{id}'\n"))
2988                .expect_err("a value that is no identifier refuses")
2989                .to_string();
2990            assert!(
2991                refusal.contains("no migration identifier"),
2992                "{id}: {refusal}"
2993            );
2994        }
2995    }
2996
2997    /// SATISFIES target-config:an-unanswered-key-is-absent-and-not-empty
2998    /// A header whose every key the writer omitted goes, and every comment
2999    /// it carried survives: standing above it, standing beside it, and in
3000    /// either position when the emptied table is the file's last.
3001    #[test]
3002    fn a_pruned_header_leaves_no_comment_behind() {
3003        let cases = [
3004            (
3005                "a middle table, comment above",
3006                "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
3007            ),
3008            (
3009                "a middle table, comment inline",
3010                "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
3011            ),
3012            (
3013                "the last table, comment above",
3014                "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
3015            ),
3016            (
3017                "the last table, comment inline",
3018                "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
3019            ),
3020        ];
3021        for (case, text) in cases {
3022            let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
3023            assert!(!next.contains("repo ="), "{case}: {next}");
3024            assert!(
3025                !next.contains("[project]"),
3026                "{case}: a table every one of whose keys dropped goes with them: {next}"
3027            );
3028            assert!(
3029                next.contains("# the operator's note"),
3030                "{case}: the comment the header carried survives: {next}"
3031            );
3032            parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
3033        }
3034    }
3035
3036    /// SATISFIES target-config:a-flag-overrides-and-a-landing-writes-back
3037    /// A key the writer retires takes the template's own comment with it
3038    /// and leaves the operator's behind, on the header of the domain the
3039    /// key belonged to. The template comment describes a key that is gone;
3040    /// the operator's comment is authored text this writer does not delete.
3041    #[test]
3042    fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
3043        let text = concat!(
3044            "schema_version = 2\n\n[project]\n",
3045            "# the operator's note\n",
3046            "repo = \"acme/widget\" # P: project path on the forge\n\n",
3047            "[git]\ntrunk = \"main\"\n"
3048        );
3049        let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
3050        assert!(!next.contains("repo ="), "{next}");
3051        assert!(!next.contains("[project]"), "{next}");
3052        assert!(
3053            next.contains("# the operator's note"),
3054            "authored text survives: {next}"
3055        );
3056        assert!(
3057            !next.contains("# P:"),
3058            "the template's comment describes a key that is gone: {next}"
3059        );
3060        parse(&next).expect("the result parses");
3061
3062        // A domain that keeps other keys keeps the note on its own header.
3063        let text = concat!(
3064            "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
3065            "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
3066            "# why this project names its own prefix\n",
3067            "line_prefix = \"stable/\"\n"
3068        );
3069        let next = super::rewrite_text(text, "profile.release.line_prefix", None)
3070            .expect("the key removes");
3071        assert!(!next.contains("line_prefix ="), "{next}");
3072        assert!(next.contains("[profile.release]"), "{next}");
3073        assert!(
3074            next.contains("# why this project names its own prefix"),
3075            "authored text survives: {next}"
3076        );
3077    }
3078}