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