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