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