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