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