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