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