Skip to main content

release_kit/
config.rs

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