Skip to main content

release_kit/
config.rs

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