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    // A value this writer rewrites keeps the comment the file already
1021    // carried, so the template's own sentence about a key outlives the
1022    // answer it describes: a target moving to local integration would read
1023    // `# F: invariant, contains all four rules` beside two of them. The
1024    // refresh replaces the template's comments alone and leaves every one
1025    // the operator wrote.
1026    changed |= migrate::refresh_comments(&mut document);
1027    if !changed {
1028        return Ok(text);
1029    }
1030    let next = document.to_string();
1031    parse(&next)?;
1032    Ok(next)
1033}
1034
1035/// Drop the named tables that are now empty, answering whether the
1036/// document changed.
1037///
1038/// `target-config:an-unanswered-key-is-absent-and-not-empty` asks the
1039/// writer to leave out a table every one of whose keys it omitted, and the
1040/// fresh render already does. This is the same rule on the update path,
1041/// which edits authored text instead: without it a target that drops its
1042/// forge keeps a bare `[project]` header, and the two writers disagree
1043/// about one resolved answer.
1044///
1045/// Only the named tables, because a table the operator authored empty is
1046/// theirs and this writer emptied nothing in it.
1047///
1048/// Every comment the removed header carried, standing above it or inline
1049/// beside it, moves to the next table, or to the end of the file where the
1050/// removed table was last. The header is this binary's; the comment may be
1051/// the operator's, and an upgrade that silently deleted one would be
1052/// losing authored text.
1053pub(crate) fn prune_empty_tables(document: &mut toml_edit::DocumentMut, names: &[&str]) -> bool {
1054    let order: Vec<String> = document
1055        .as_table()
1056        .iter()
1057        .map(|(key, _)| key.to_owned())
1058        .collect();
1059    let mut changed = false;
1060    for (index, name) in order.iter().enumerate() {
1061        if !names.contains(&name.as_str())
1062            || !document
1063                .get(name)
1064                .and_then(toml_edit::Item::as_table)
1065                .is_some_and(toml_edit::Table::is_empty)
1066        {
1067            continue;
1068        }
1069        let carried = document
1070            .get(name)
1071            .and_then(toml_edit::Item::as_table)
1072            .and_then(|table| carried_comment(table.decor()));
1073        document.remove(name);
1074        changed = true;
1075        let Some(carried) = carried else { continue };
1076        // The next header the file actually prints: an implicit table
1077        // emits no header of its own, so its decor would take the comment
1078        // out of the rendered text with it.
1079        let next = order
1080            .iter()
1081            .skip(index + 1)
1082            .find(|name| {
1083                document
1084                    .get(name)
1085                    .and_then(toml_edit::Item::as_table)
1086                    .is_some_and(|table| !table.is_implicit())
1087            })
1088            .cloned();
1089        if let Some(next) = next
1090            && let Some(table) = document
1091                .get_mut(&next)
1092                .and_then(toml_edit::Item::as_table_mut)
1093        {
1094            let existing = table
1095                .decor()
1096                .prefix()
1097                .and_then(toml_edit::RawString::as_str)
1098                .unwrap_or_default()
1099                .trim_start_matches('\n')
1100                .to_owned();
1101            table
1102                .decor_mut()
1103                .set_prefix(format!("\n{carried}{existing}"));
1104        } else {
1105            let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1106            if !trailing.is_empty() && !trailing.ends_with('\n') {
1107                trailing.push('\n');
1108            }
1109            trailing.push_str(&carried);
1110            document.set_trailing(trailing);
1111        }
1112    }
1113    changed
1114}
1115
1116/// Every comment in a decor, one per line, or `None` where it carries
1117/// none.
1118///
1119/// An inline comment beside a header becomes a free-standing line, because
1120/// the header it sat beside is going and a comment needs a line of its own
1121/// to survive.
1122fn carried_comment(decor: &toml_edit::Decor) -> Option<String> {
1123    let mut lines = String::new();
1124    for raw in [decor.prefix(), decor.suffix()] {
1125        let Some(text) = raw.and_then(toml_edit::RawString::as_str) else {
1126            continue;
1127        };
1128        for line in text
1129            .lines()
1130            .map(str::trim)
1131            .filter(|line| line.starts_with('#'))
1132            .filter(|line| !is_template_comment(line))
1133        {
1134            lines.push_str(line);
1135            lines.push('\n');
1136        }
1137    }
1138    (!lines.is_empty()).then_some(lines)
1139}
1140
1141/// Whether the comment is the template's own rather than the operator's.
1142///
1143/// The template marks every comment it writes with the class of the key it
1144/// sits beside: `# P:` a landing parameter, `# N:` a free name, `# F:` an
1145/// invariant floor. A comment for a key that is going describes nothing
1146/// once the key is gone, so it goes too, while anything the operator wrote
1147/// is carried. `refresh_comment` owns the same three markers on the
1148/// migration path.
1149fn is_template_comment(line: &str) -> bool {
1150    let rest = line.trim_start_matches('#').trim_start();
1151    ["P:", "N:", "F:"]
1152        .iter()
1153        .any(|marker| rest.starts_with(marker))
1154}
1155
1156/// Put carried comment lines where the rendered file will still show
1157/// them: above the first header it prints, or at its end where it prints
1158/// none.
1159///
1160/// The last resort for text whose own domain is not rendered. A comment
1161/// with no home is still the operator's, and the end of the file is where
1162/// it survives.
1163pub(crate) fn place_carried(document: &mut toml_edit::DocumentMut, carried: &str) {
1164    let first = document
1165        .as_table()
1166        .iter()
1167        .find(|(_, item)| item.as_table().is_some_and(|table| !table.is_implicit()))
1168        .map(|(name, _)| name.to_owned());
1169    if let Some(first) = first
1170        && let Some(table) = document
1171            .get_mut(&first)
1172            .and_then(toml_edit::Item::as_table_mut)
1173    {
1174        let existing = table
1175            .decor()
1176            .prefix()
1177            .and_then(toml_edit::RawString::as_str)
1178            .unwrap_or_default()
1179            .trim_start_matches('\n')
1180            .to_owned();
1181        table
1182            .decor_mut()
1183            .set_prefix(format!("\n{carried}{existing}"));
1184        return;
1185    }
1186    let mut trailing = document.trailing().as_str().unwrap_or_default().to_owned();
1187    if !trailing.is_empty() && !trailing.ends_with('\n') {
1188        trailing.push('\n');
1189    }
1190    trailing.push_str(carried);
1191    document.set_trailing(trailing);
1192}
1193
1194/// The operator's comments standing above `key`, or `None` where it
1195/// carries none.
1196///
1197/// Read before a move, so the comment travels with the value it describes
1198/// rather than staying beside a key that is gone.
1199pub(crate) fn key_comments(table: &toml_edit::Table, key: &str) -> Option<String> {
1200    let (name, _) = table.get_key_value(key)?;
1201    carried_comment(name.leaf_decor())
1202}
1203
1204/// Put `carried` above `key`, keeping whatever decor it already has.
1205pub(crate) fn set_key_comments(table: &mut toml_edit::Table, key: &str, carried: &str) {
1206    let Some(mut name) = table.key_mut(key) else {
1207        return;
1208    };
1209    let decor = name.leaf_decor_mut();
1210    let existing = decor
1211        .prefix()
1212        .and_then(toml_edit::RawString::as_str)
1213        .unwrap_or_default()
1214        .trim_start_matches('\n')
1215        .to_owned();
1216    decor.set_prefix(format!("\n{carried}{existing}"));
1217}
1218
1219/// The nearest known name to `unknown`, for a refusal that helps.
1220#[must_use]
1221pub(crate) fn nearest_known<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
1222    known
1223        .iter()
1224        .min_by_key(|name| distance(unknown, name))
1225        .copied()
1226}
1227
1228/// The operator's comments standing on a table's own header, removed from
1229/// it.
1230pub(crate) fn take_header_comments(table: &mut toml_edit::Table) -> Option<String> {
1231    let carried = carried_comment(table.decor())?;
1232    table.decor_mut().set_prefix("\n");
1233    Some(carried)
1234}
1235
1236/// Remove `key` from `table`, answering the operator's comments it
1237/// carried.
1238///
1239/// A comment the operator wrote above or beside a key outlives the answer
1240/// it described: `project-profile:a-schema-one-configuration-migrates-in-place`
1241/// asks for every free-standing comment to survive, and an upgrade that
1242/// retires a key is the same promise on the other writer. The comments
1243/// move to the table's own header, where they read as a note on the domain
1244/// the key belonged to, and travel further with that header if the table
1245/// itself empties.
1246pub(crate) fn take_comments(table: &mut toml_edit::Table, key: &str) -> bool {
1247    let carried = table.get_key_value(key).and_then(|(name, item)| {
1248        let mut lines = carried_comment(name.leaf_decor()).unwrap_or_default();
1249        if let Some(value) = item.as_value()
1250            && let Some(more) = carried_comment(value.decor())
1251        {
1252            lines.push_str(&more);
1253        }
1254        (!lines.is_empty()).then_some(lines)
1255    });
1256    let removed = table.remove(key).is_some();
1257    if let Some(carried) = carried {
1258        let existing = table
1259            .decor()
1260            .prefix()
1261            .and_then(toml_edit::RawString::as_str)
1262            .unwrap_or_default()
1263            .trim_start_matches('\n')
1264            .to_owned();
1265        table
1266            .decor_mut()
1267            .set_prefix(format!("\n{carried}{existing}"));
1268    }
1269    removed
1270}
1271
1272/// Set or remove one key in an open document, answering whether the
1273/// document changed. No validation: the caller validates the whole.
1274fn apply_key(
1275    document: &mut toml_edit::DocumentMut,
1276    key: &str,
1277    value: Option<toml_edit::Value>,
1278) -> Result<bool, RkError> {
1279    let segments: Vec<&str> = key.split('.').collect();
1280    // Every key in `PARAMETER_KEYS` has at least one segment, so the split
1281    // answers; a key that did not would have refused above.
1282    let Some((last, parents)) = segments.split_last() else {
1283        return Err(invalid(format!("{key} names no key")));
1284    };
1285    let Some(mut value) = value else {
1286        let mut item = document.as_item_mut();
1287        for segment in parents {
1288            if item.get(segment).is_none() {
1289                return Ok(false);
1290            }
1291            item = &mut item[segment];
1292        }
1293        let removed = item
1294            .as_table_mut()
1295            .is_some_and(|table| take_comments(table, last));
1296        return Ok(removed);
1297    };
1298    let mut item = document.as_item_mut();
1299    for segment in parents {
1300        if item.get(segment).is_none() {
1301            let mut table = toml_edit::Table::new();
1302            table.set_implicit(true);
1303            item[segment] = toml_edit::Item::Table(table);
1304        }
1305        item = &mut item[segment];
1306    }
1307    if let Some(old) = item.get(last).and_then(toml_edit::Item::as_value) {
1308        if old.to_string().trim() == value.to_string().trim() {
1309            return Ok(false);
1310        }
1311        *value.decor_mut() = old.decor().clone();
1312    } else if let Some(comment) = migrate::template_comment(&segments) {
1313        // A key this writer is adding rather than changing carries no
1314        // decor of the operator's, so it takes the template's own
1315        // comment. Without this a parameter that arrives in a later
1316        // release lands bare in every existing target's configuration,
1317        // beside keys that all state their class and their meaning.
1318        value.decor_mut().set_suffix(comment);
1319    }
1320    item[last] = toml_edit::Item::Value(value);
1321    Ok(true)
1322}
1323
1324/// Resolved landing input, including every key a preview would write.
1325#[derive(Debug, Clone, serde::Serialize)]
1326pub struct Plan {
1327    /// Added or updated configuration.
1328    pub action: &'static str,
1329    /// Keys whose configured answers differ from the record.
1330    pub changes: Vec<String>,
1331    /// The exact authored TOML the apply writes.
1332    pub content: String,
1333}
1334
1335impl Plan {
1336    /// Resolve the output without writing it; existing comments survive.
1337    ///
1338    /// # Errors
1339    /// Propagates unreadable or invalid configuration.
1340    pub fn new(
1341        target: &Path,
1342        params: &crate::landing::Params,
1343        existing: Option<&Config>,
1344        record: Option<&crate::landing::manifest::Manifest>,
1345    ) -> Result<Self, RkError> {
1346        let text = existing
1347            .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
1348            .transpose()?;
1349        Self::compose(text.as_deref(), params, existing, record)
1350    }
1351
1352    /// Resolve the output from the existing text already read, so a
1353    /// planner that owns no filesystem can compose it from its
1354    /// observation; existing comments survive.
1355    ///
1356    /// # Errors
1357    /// Propagates invalid configuration.
1358    pub fn compose(
1359        text: Option<&str>,
1360        params: &crate::landing::Params,
1361        existing: Option<&Config>,
1362        record: Option<&crate::landing::manifest::Manifest>,
1363    ) -> Result<Self, RkError> {
1364        let mut resolved = existing.cloned().unwrap_or_default();
1365        resolved.schema_version = SCHEMA_VERSION;
1366        params.repo().clone_into(&mut resolved.project.repo);
1367        resolved.profile = Profile {
1368            technologies: Some(params.technologies().to_vec()),
1369            forge: Some(params.forge().unwrap_or_default().to_owned()),
1370            release: Release {
1371                mode: Some(params.release_mode()),
1372                driver: params.driver().map(str::to_owned),
1373                style: params.style(),
1374                line_prefix: params.profile().release.line_prefix.clone(),
1375            },
1376        };
1377        resolved.git = Git {
1378            trunk: Some(params.trunk().to_owned()),
1379            checkout_mode: Some(params.checkout_mode()),
1380            integration: Some(params.integration()),
1381        };
1382        resolved.protection = protection_for(
1383            &resolved.protection,
1384            params.integration(),
1385            existing.is_none(),
1386        );
1387        resolved.capabilities = Capabilities {
1388            nix_packaging: Some(params.nix_packaging()),
1389            reporting_policy: Some(params.reporting_policy()),
1390            scorecard: Some(params.scorecard()),
1391            code_scanning: Some(
1392                params
1393                    .code_scanning()
1394                    .map_or("off", crate::landing::Provider::as_str)
1395                    .to_owned(),
1396            ),
1397        };
1398        resolved.security.contact = Some(params.security_contact().to_owned());
1399        resolved.security.response = Some(params.security_response().to_owned());
1400        let content = if let Some(text) = text.filter(|_| existing.is_some()) {
1401            rewrite_all(text, parameter_values(&resolved))?
1402        } else {
1403            String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
1404        };
1405        parse(&content)?;
1406        Ok(Self {
1407            action: if existing.is_some() {
1408                "updated"
1409            } else {
1410                "added"
1411            },
1412            changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
1413            content,
1414        })
1415    }
1416
1417    /// Write the prepared configuration before the landing record.
1418    ///
1419    /// # Errors
1420    /// Propagates an atomic write failure.
1421    pub fn apply(&self, target: &Path) -> Result<(), RkError> {
1422        crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
1423        Ok(())
1424    }
1425}
1426
1427/// Every class P key with its value, `None` for a key the answers omit.
1428fn parameter_values(config: &Config) -> Vec<(&'static str, Option<toml_edit::Value>)> {
1429    let mut values: Vec<(&'static str, Option<toml_edit::Value>)> = vec![(
1430        "project.repo",
1431        (!config.project.repo.is_empty()).then(|| config.project.repo.clone().into()),
1432    )];
1433    if let Some(list) = &config.profile.technologies {
1434        values.push(("profile.technologies", Some(array(list))));
1435    }
1436    if let Some(forge) = config.profile.forge.clone() {
1437        values.push(("profile.forge", Some(forge.into())));
1438    }
1439    if let Some(mode) = config.profile.release.mode {
1440        values.push(("profile.release.mode", Some(mode.as_str().into())));
1441        values.push((
1442            "profile.release.driver",
1443            config
1444                .profile
1445                .release
1446                .driver
1447                .clone()
1448                .map(toml_edit::Value::from),
1449        ));
1450        values.push((
1451            "profile.release.style",
1452            config
1453                .profile
1454                .release
1455                .style
1456                .map(|style| style.as_str().into()),
1457        ));
1458        values.push((
1459            "profile.release.line_prefix",
1460            config
1461                .profile
1462                .release
1463                .line_prefix
1464                .clone()
1465                .map(toml_edit::Value::from),
1466        ));
1467    }
1468    if let Some(trunk) = config.git.trunk.clone() {
1469        values.push(("git.trunk", Some(trunk.into())));
1470    }
1471    if let Some(mode) = config.git.checkout_mode {
1472        values.push(("git.checkout_mode", Some(mode.as_str().into())));
1473    }
1474    if let Some(mode) = config.git.integration {
1475        values.push(("git.integration", Some(mode.as_str().into())));
1476        // The pair that authority decides travels with it: a write-back
1477        // that moved the mode alone would leave a configuration the
1478        // floor table refuses on the next read.
1479        let mut rules = toml_edit::Array::new();
1480        for rule in &config.protection.owned_trunk_rules {
1481            rules.push(rule.as_str());
1482        }
1483        values.push((
1484            "protection.owned_trunk_rules",
1485            Some(toml_edit::Value::Array(rules)),
1486        ));
1487        values.push((
1488            "protection.gitlab.push_access_level",
1489            Some(config.protection.gitlab.push_access_level.into()),
1490        ));
1491    }
1492    if let Some(value) = config.capabilities.nix_packaging {
1493        values.push(("capabilities.nix_packaging", Some(value.into())));
1494    }
1495    if let Some(value) = config.capabilities.reporting_policy {
1496        values.push(("capabilities.reporting_policy", Some(value.into())));
1497    }
1498    if let Some(value) = config.capabilities.scorecard {
1499        values.push(("capabilities.scorecard", Some(value.into())));
1500    }
1501    if let Some(value) = config.capabilities.code_scanning.clone() {
1502        values.push(("capabilities.code_scanning", Some(value.into())));
1503    }
1504    // An empty contact is an answer, not an absence: it resets the landed
1505    // policy to the forge's own prose, so it projects like any other value.
1506    if let Some(value) = config.security.contact.clone() {
1507        values.push(("security.contact", Some(value.into())));
1508    }
1509    if let Some(value) = config.security.response.clone() {
1510        values.push(("security.response", Some(value.into())));
1511    }
1512    values
1513}
1514
1515/// Only explicit class P answers can be pending; comparisons still use the record.
1516#[must_use]
1517pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
1518    let recorded = {
1519        let params = crate::landing::Params::from_record(record);
1520        Plan::compose(None, &params, None, None)
1521            .ok()
1522            .and_then(|plan| parse(&plan.content).ok())
1523    };
1524    let Some(recorded) = recorded else {
1525        return Vec::new();
1526    };
1527    let render = |value: &Option<toml_edit::Value>| {
1528        value.as_ref().map_or_else(
1529            || "<absent>".to_owned(),
1530            |value| value.to_string().trim().to_owned(),
1531        )
1532    };
1533    let baseline: Vec<(&str, String)> = parameter_values(&recorded)
1534        .iter()
1535        .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1536        .map(|(key, value)| (*key, render(value)))
1537        .collect();
1538    parameter_values(config)
1539        .into_iter()
1540        .filter(|(key, _)| !DERIVED_POLICY_KEYS.contains(key))
1541        .map(|(key, value)| (key, render(&value)))
1542        .filter(|(key, value)| {
1543            baseline
1544                .iter()
1545                .any(|(other, old)| key == other && value != old)
1546        })
1547        .map(|(key, _)| key.to_owned())
1548        .collect()
1549}
1550
1551/// The compiled protection floors a locally integrated trunk carries.
1552///
1553/// The ordinary defaults describe forge integration, because that is the
1554/// shape this convention had before the authority became an axis. A
1555/// locally integrated trunk drops the two rules no forge can apply to a
1556/// push, and takes the narrowest GitLab level that still admits the push
1557/// its integrations end in.
1558#[must_use]
1559fn local_protection() -> Protection {
1560    /// The narrowest GitLab access level that still admits a push.
1561    const MAINTAINER: i64 = 40;
1562    let mut policy = Protection::default();
1563    policy
1564        .owned_trunk_rules
1565        .retain(|rule| rule != "pull_request" && rule != "required_status_checks");
1566    policy.gitlab.push_access_level = MAINTAINER;
1567    policy
1568}
1569
1570/// The protection values a landing writes, for one integration authority.
1571///
1572/// Exactly two keys differ between the authorities, and they are the two
1573/// this looks at: the owned trunk rules, and the GitLab push access
1574/// level. A target whose pair matches one authority's compiled defaults
1575/// never stated them; it took them, so a landing that resolves the other
1576/// authority writes that authority's pair instead, and a fresh landing
1577/// writes its own. A target whose pair matches neither is one an operator
1578/// narrowed or widened, and it keeps every value it stated: the floor
1579/// table already judged it under the same mode, and an operator who
1580/// changed a protection meant it.
1581///
1582/// The pair alone, never the whole policy: every other key here is a name
1583/// or a review policy the authority does not decide, and a target that
1584/// renamed its ruleset would otherwise read as having stated the pair.
1585///
1586/// This is what makes the committed configuration the one source: the
1587/// floors judge these values, the setup installs them, and its check
1588/// reads them back, so a local-integration target is never handed a trunk
1589/// its own integrations cannot push.
1590#[must_use]
1591fn protection_for(held: &Protection, integration: Integration, fresh: bool) -> Protection {
1592    let forge = Protection::default();
1593    let local = local_protection();
1594    let pair = |policy: &Protection| {
1595        (
1596            policy.owned_trunk_rules.clone(),
1597            policy.gitlab.push_access_level,
1598        )
1599    };
1600    let taken = fresh || pair(held) == pair(&forge) || pair(held) == pair(&local);
1601    if !taken {
1602        return held.clone();
1603    }
1604    let mut next = held.clone();
1605    let source = match integration {
1606        Integration::Forge => forge,
1607        Integration::Local => local,
1608    };
1609    next.owned_trunk_rules = source.owned_trunk_rules;
1610    next.gitlab.push_access_level = source.gitlab.push_access_level;
1611    next
1612}
1613
1614/// The trunk accessor for callers without a setup context.
1615///
1616/// # Errors
1617/// Propagates invalid configuration and I/O failures.
1618pub fn trunk_of(target: &Path) -> Result<String, RkError> {
1619    Ok(load(target)?
1620        .and_then(|config| config.git.trunk)
1621        .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
1622}
1623
1624/// The release-line prefix for callers without a setup context.
1625///
1626/// # Errors
1627/// Propagates invalid configuration and I/O failures.
1628pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
1629    Ok(load(target)?
1630        .and_then(|config| config.profile.release.line_prefix)
1631        .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
1632}
1633
1634#[cfg(test)]
1635mod tests {
1636    use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
1637    use crate::landing::{CheckoutMode, Integration, Style};
1638    use crate::profile::ReleaseMode;
1639
1640    /// The resolved defaults a landing writes for an automatic rust
1641    /// release on GitHub.
1642    fn resolved_defaults() -> Config {
1643        Config {
1644            project: super::Project {
1645                repo: "acme/widget".into(),
1646            },
1647            profile: super::Profile {
1648                technologies: Some(vec!["rust".into()]),
1649                forge: Some("github".into()),
1650                release: super::Release {
1651                    mode: Some(ReleaseMode::Automatic),
1652                    driver: Some("rust".into()),
1653                    style: Some(Style::Trunk),
1654                    line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
1655                },
1656            },
1657            git: super::Git {
1658                trunk: Some(super::TRUNK_DEFAULT.into()),
1659                checkout_mode: Some(CheckoutMode::LinkedWorktree),
1660                integration: Some(Integration::Local),
1661            },
1662            capabilities: super::Capabilities {
1663                nix_packaging: Some(false),
1664                reporting_policy: Some(true),
1665                scorecard: Some(false),
1666                code_scanning: Some("off".to_owned()),
1667            },
1668            // Writing states both security answers, so a reader sees the
1669            // policy the target landed rather than an implied one.
1670            security: super::Security {
1671                contact: Some(String::new()),
1672                response: Some(super::RESPONSE_DEFAULT.into()),
1673                ..super::Security::default()
1674            },
1675            // Writing resolves the derived ruleset name, so the file states
1676            // the name the setup installs rather than leaving it implied.
1677            protection: super::Protection {
1678                trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
1679                ..super::Protection::default()
1680            },
1681            ..Config::default()
1682        }
1683    }
1684
1685    #[test]
1686    fn an_omitted_key_is_distinguishable_from_an_explicit_default() {
1687        let omitted = parse("schema_version = 2\n").expect("omitted answers parse");
1688        let explicit = parse(
1689            "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",
1690        )
1691        .expect("explicit defaults parse");
1692        assert_eq!(omitted.git, super::Git::default());
1693        assert_eq!(omitted.capabilities, super::Capabilities::default());
1694        assert_eq!(
1695            explicit.git.checkout_mode,
1696            Some(CheckoutMode::LinkedWorktree)
1697        );
1698        assert_eq!(explicit.profile.release.style, Some(Style::Trunk));
1699        assert_eq!(explicit.capabilities.nix_packaging, Some(false));
1700        assert_eq!(explicit.capabilities.reporting_policy, Some(true));
1701        assert_eq!(explicit.capabilities.scorecard, Some(false));
1702        assert_eq!(explicit.capabilities.code_scanning.as_deref(), Some("off"));
1703        assert_ne!(omitted, explicit);
1704        // The older spellings of the checkout mode still read.
1705        let older = parse("schema_version = 2\n[git]\ncheckout_mode = 'worktree'\n")
1706            .expect("the older spelling reads");
1707        assert_eq!(older.git.checkout_mode, Some(CheckoutMode::LinkedWorktree));
1708    }
1709
1710    /// A configuration written by 0.3.13 carries `installation_id`, which
1711    /// this version reads and ignores. Refusing it would strand every
1712    /// target that release landed.
1713    #[test]
1714    fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
1715        let dir = tempfile::tempdir().expect("a tempdir");
1716        std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
1717        std::fs::write(
1718            dir.path().join(CONFIG_PATH),
1719            "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
1720        )
1721        .expect("the config writes");
1722        let held = load(dir.path())
1723            .expect("the config reads")
1724            .expect("it is present");
1725        assert_eq!(held.setup.bot.app_id, "123");
1726        assert_eq!(
1727            held.setup.bot.installation_id,
1728            Some(0),
1729            "the key parses; nothing reads it"
1730        );
1731    }
1732
1733    /// SATISFIES project-profile:a-schema-one-configuration-migrates-in-place
1734    #[test]
1735    fn a_schema_1_config_migrates_into_its_domains() {
1736        let held = parse(
1737            "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",
1738        )
1739        .expect("a schema 1 file reads");
1740        assert_eq!(held.schema_version, 2);
1741        assert_eq!(held.profile.technologies, Some(vec!["bash".to_owned()]));
1742        assert_eq!(held.profile.forge.as_deref(), Some("gitlab"));
1743        assert_eq!(held.profile.release.mode, Some(ReleaseMode::Automatic));
1744        assert_eq!(held.profile.release.driver.as_deref(), Some("bash"));
1745        assert_eq!(held.profile.release.style, Some(Style::Lines));
1746        assert_eq!(held.profile.release.line_prefix.as_deref(), Some("stable/"));
1747        assert_eq!(held.git.trunk.as_deref(), Some("main"));
1748        assert_eq!(held.git.checkout_mode, Some(CheckoutMode::MainWorktree));
1749        assert_eq!(held.capabilities.nix_packaging, Some(false));
1750        assert_eq!(held.capabilities.reporting_policy, Some(true));
1751    }
1752
1753    /// SATISFIES project-profile:release-intent-has-three-modes
1754    #[test]
1755    fn every_invalid_release_state_names_its_key() {
1756        for (text, key) in [
1757            (
1758                "[profile.release]\nmode = 'none'\nstyle = 'trunk'\n",
1759                "profile.release.style",
1760            ),
1761            (
1762                "[profile.release]\nmode = 'external'\ndriver = 'rust'\n",
1763                "profile.release.driver",
1764            ),
1765            (
1766                "[profile.release]\nmode = 'none'\nline_prefix = 'release/'\n",
1767                "profile.release.line_prefix",
1768            ),
1769            (
1770                "[profile]\ntechnologies = ['rust', 'rust']\n",
1771                "profile.technologies",
1772            ),
1773            (
1774                "[profile]\ntechnologies = ['Rust!']\n",
1775                "profile.technologies",
1776            ),
1777            ("[profile]\nforge = 'Git Hub'\n", "profile.forge"),
1778            (
1779                "[profile.release]\nmode = 'manual'\n",
1780                "profile.release.mode",
1781            ),
1782        ] {
1783            let error = parse(&format!("schema_version = 2\n{text}"))
1784                .expect_err("an invalid release state refuses")
1785                .to_string();
1786            assert!(error.contains(key), "{key}: {error}");
1787        }
1788    }
1789
1790    /// A parameter that arrives in a later release lands beside the keys
1791    /// that were already there, stating its class and its meaning the way
1792    /// they do, rather than bare.
1793    #[test]
1794    fn a_newly_written_key_takes_the_templates_comment() {
1795        let text = "schema_version = 2\n\n[git]\ntrunk = 'main' # mine\n";
1796        let next = super::rewrite_text(
1797            text,
1798            "git.integration",
1799            Some(toml_edit::Value::from("local")),
1800        )
1801        .expect("the key writes");
1802        assert!(
1803            next.contains("integration = \"local\" # P: local or forge"),
1804            "{next}"
1805        );
1806        // A key the operator already commented keeps their words.
1807        let next = super::rewrite_text(&next, "git.trunk", Some(toml_edit::Value::from("master")))
1808            .expect("the key writes");
1809        assert!(next.contains("trunk = \"master\" # mine"), "{next}");
1810    }
1811
1812    #[test]
1813    fn the_landed_config_template_round_trips() {
1814        let dir = tempfile::tempdir().expect("a target exists");
1815        let mut config = Config::default();
1816        config.project.repo = "acme/nested/widget".into();
1817        config.profile.technologies = Some(vec!["bash".into(), "python".into()]);
1818        config.profile.forge = Some("gitlab".into());
1819        config.profile.release = super::Release {
1820            mode: Some(ReleaseMode::Automatic),
1821            driver: Some("bash".into()),
1822            style: Some(Style::Lines),
1823            line_prefix: Some("stable/".into()),
1824        };
1825        config.git.trunk = Some("main".into());
1826        config.git.checkout_mode = Some(CheckoutMode::MainWorktree);
1827        config.git.integration = Some(Integration::Forge);
1828        config.capabilities.nix_packaging = Some(true);
1829        config.capabilities.reporting_policy = Some(false);
1830        config.capabilities.scorecard = Some(true);
1831        config.capabilities.code_scanning = Some("semgrep".to_owned());
1832        // The escaping subject moved to the one unrestricted string in this
1833        // table: `contact` is now a class P value the reader holds to a
1834        // single control-free line, so it can carry neither.
1835        config.security.advisories =
1836            "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
1837        config.security.contact = Some("security team, room 3 \"the vault\"".into());
1838        config.security.response = Some("14 business days".into());
1839        config.setup.required_check = "build / test".into();
1840        config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
1841        config.setup.release_lines = true;
1842        config.setup.excluded_steps = [
1843            (
1844                "package-check".to_owned(),
1845                "nothing is published".to_owned(),
1846            ),
1847            (
1848                "protect-trunk".to_owned(),
1849                "this project merges \"locally\"".to_owned(),
1850            ),
1851        ]
1852        .into_iter()
1853        .collect();
1854        config.setup.bot.app_id = "123".into();
1855        config.protection.trunk_ruleset = Some("primary".into());
1856        config.protection.tag_ruleset = "versions".into();
1857        config.protection.lines_ruleset = "maintenance".into();
1858        config.protection.title_check = "intent".into();
1859        config.protection.tag_pattern = "refs/tags/*".into();
1860        config
1861            .protection
1862            .owned_trunk_rules
1863            .push("required_signatures".into());
1864        config.protection.required_approving_review_count = 2;
1865        config.protection.dismiss_stale_reviews_on_push = true;
1866        config.protection.require_code_owner_review = true;
1867        config.protection.require_last_push_approval = true;
1868        config.protection.gitlab.squash_commit_template =
1869            "%{title}\n\nContext: %{description}".into();
1870        config.protection.gitlab.merge_access_level = 40;
1871        // A release-less profile with no forge: the automatic-only keys
1872        // and the repository are absent from the written file.
1873        let mut release_less = resolved_defaults();
1874        release_less.project.repo = String::new();
1875        release_less.profile.technologies = Some(Vec::new());
1876        release_less.profile.forge = Some(String::new());
1877        release_less.profile.release = super::Release {
1878            mode: Some(ReleaseMode::None),
1879            driver: None,
1880            style: None,
1881            line_prefix: None,
1882        };
1883        release_less.capabilities.reporting_policy = Some(false);
1884        for expected in [resolved_defaults(), config, release_less] {
1885            write(dir.path(), &expected).expect("the template renders");
1886            assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
1887            let text =
1888                std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1889            assert!(text.contains("# P: every technology"));
1890            assert!(text.contains("# F: invariant"));
1891        }
1892        let text = std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1893        assert!(
1894            !text.contains("style ="),
1895            "a none release writes no style: {text}"
1896        );
1897        assert!(!text.contains("repo ="), "no forge writes no repo: {text}");
1898    }
1899
1900    #[test]
1901    fn a_config_with_an_unknown_key_refuses_by_name() {
1902        for (table, typo, nearest) in [
1903            ("", "schemax_version", "schema_version"),
1904            ("git", "trunkx", "trunk"),
1905            ("profile.release", "stile", "style"),
1906            ("capabilities", "nix_packagingg", "nix_packaging"),
1907            ("security", "contactx", "contact"),
1908            ("setup", "required_checkx", "required_check"),
1909            ("setup.bot", "app_i", "app_id"),
1910            ("protection", "trunk_rulesett", "trunk_ruleset"),
1911            (
1912                "protection.github",
1913                "squash_body_sourcex",
1914                "squash_body_source",
1915            ),
1916            ("protection.gitlab", "squash_optionx", "squash_option"),
1917        ] {
1918            let header = if table.is_empty() {
1919                String::new()
1920            } else {
1921                format!("[{table}]\n")
1922            };
1923            let text = format!("schema_version = 2\n{header}{typo} = 'value'\n");
1924            let error = parse(&text).expect_err("unknown keys refuse").to_string();
1925            for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
1926                assert!(error.contains(expected), "{error}");
1927            }
1928        }
1929    }
1930
1931    /// An exclusion removes a step from scope, so a typo in one would
1932    /// silently keep judging a step the target does not run, and a
1933    /// reasonless one would leave a report nobody can audit.
1934    #[test]
1935    fn an_exclusion_names_a_real_step_and_states_why() {
1936        for (text, expected) in [
1937            (
1938                "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
1939                vec!["protect-trunkk", "nearest known step: protect-trunk"],
1940            ),
1941            (
1942                "[setup.excluded_steps]\nprotect-trunk = '  '\n",
1943                vec!["protect-trunk", "no reason"],
1944            ),
1945        ] {
1946            let error = parse(&format!("schema_version = 2\n{text}"))
1947                .expect_err("the exclusion refuses")
1948                .to_string();
1949            for want in expected {
1950                assert!(error.contains(want), "{error}");
1951            }
1952        }
1953        let held = parse(
1954            "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
1955        )
1956        .expect("a named step with a reason parses");
1957        assert_eq!(
1958            held.setup
1959                .excluded_steps
1960                .get("protect-trunk")
1961                .map(String::as_str),
1962            Some("we merge locally")
1963        );
1964    }
1965
1966    /// An exclusion narrows what the setup judges. It never weakens the
1967    /// method's policy, so the floors bind a target that runs a subset
1968    /// exactly as they bind one that runs every step.
1969    #[test]
1970    fn an_exclusion_does_not_lift_a_floor() {
1971        let error = parse(
1972            "schema_version = 2\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
1973        )
1974        .expect_err("the floor binds an excluded step's keys too")
1975        .to_string();
1976        assert!(
1977            error.contains("protection.allowed_merge_methods"),
1978            "{error}"
1979        );
1980    }
1981
1982    #[test]
1983    fn a_config_at_an_unknown_schema_refuses() {
1984        for text in ["schema_version = 999", "schema_version = '2'", ""] {
1985            let error = parse(text)
1986                .expect_err("a schema must be declared and known")
1987                .to_string();
1988            assert!(
1989                error.contains(CONFIG_PATH) && error.contains("schema_version"),
1990                "{error}"
1991            );
1992        }
1993    }
1994
1995    #[test]
1996    fn an_unparsable_config_refuses_naming_the_position() {
1997        let error = parse("schema_version = 2\n[project\n")
1998            .expect_err("bad TOML refuses")
1999            .to_string();
2000        for expected in [CONFIG_PATH, "line 2", "column"] {
2001            assert!(error.contains(expected), "{error}");
2002        }
2003    }
2004
2005    #[test]
2006    fn an_absent_config_reads_as_none() {
2007        let dir = tempfile::tempdir().expect("a target exists");
2008        assert_eq!(load(dir.path()).expect("absence is compatible"), None);
2009        assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
2010    }
2011
2012    #[test]
2013    fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
2014        let dir = tempfile::tempdir().expect("a target exists");
2015        std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2016        std::fs::write(
2017            dir.path().join(CONFIG_PATH),
2018            "schema_version = 2\n[protection]\nstrict_required_status_checks = false\n",
2019        )
2020        .expect("a config exists");
2021        let error =
2022            trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
2023        assert_eq!(error.exit_code(), 73);
2024        assert!(
2025            error
2026                .to_string()
2027                .contains("protection.strict_required_status_checks")
2028        );
2029    }
2030
2031    #[test]
2032    fn rewrite_key_preserves_comments() {
2033        let dir = tempfile::tempdir().expect("a target exists");
2034        std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
2035        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";
2036        let path = dir.path().join(CONFIG_PATH);
2037        std::fs::write(&path, original).expect("a config exists");
2038        rewrite_key(dir.path(), "profile.release.style", "lines".into())
2039            .expect("the style writes back");
2040        let text = std::fs::read_to_string(&path).expect("the text reads");
2041        assert_eq!(text, original.replace("'trunk'", "\"lines\""));
2042        assert_eq!(
2043            load(dir.path())
2044                .expect("the config reads")
2045                .expect("present")
2046                .profile
2047                .release
2048                .style,
2049            Some(Style::Lines)
2050        );
2051        rewrite_key(dir.path(), "project.repo", "acme/widget".into())
2052            .expect("an omitted table can be added");
2053        assert_eq!(
2054            load(dir.path())
2055                .expect("reads")
2056                .expect("present")
2057                .project
2058                .repo,
2059            "acme/widget"
2060        );
2061        rewrite_key(
2062            dir.path(),
2063            "security.contact",
2064            "security@acme.example".into(),
2065        )
2066        .expect("the contact is a landing parameter");
2067        rewrite_key(dir.path(), "security.response", "14 days".into())
2068            .expect("the response is a landing parameter");
2069        let held = load(dir.path()).expect("reads").expect("present");
2070        assert_eq!(
2071            held.security.contact.as_deref(),
2072            Some("security@acme.example")
2073        );
2074        assert_eq!(held.security.response.as_deref(), Some("14 days"));
2075        let text = std::fs::read_to_string(&path).expect("the text reads");
2076        assert!(text.contains("# keep me"), "the comment survives: {text}");
2077        let before = std::fs::read(&path).expect("the bytes read");
2078        for (key, value) in [
2079            ("security.advisories", "acme/private"),
2080            ("security.response", "90d"),
2081            ("profile.release.style", "unknown"),
2082        ] {
2083            assert!(rewrite_key(dir.path(), key, value.into()).is_err());
2084            assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
2085        }
2086    }
2087
2088    /// The two security answers are one line and one narrow grammar,
2089    /// because both land verbatim in a public policy.
2090    #[test]
2091    fn the_security_answers_are_held_to_their_grammar() {
2092        for value in ["team@acme.example", "  https://acme.example/report  ", ""] {
2093            super::canonical_contact(value).expect("a control-free line is a contact");
2094        }
2095        for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
2096            let refusal = super::canonical_contact(value).expect_err("a control character refuses");
2097            assert!(refusal.contains("security.contact"), "{refusal}");
2098        }
2099        assert_eq!(
2100            super::canonical_contact("  team@acme.example  "),
2101            Ok("team@acme.example".to_owned()),
2102            "surrounding whitespace is trimmed"
2103        );
2104        for value in [
2105            "best-effort",
2106            "1 day",
2107            "2 days",
2108            "14 days",
2109            "1 business day",
2110            "14 business days",
2111        ] {
2112            assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
2113        }
2114        assert_eq!(
2115            super::canonical_response(""),
2116            Ok(super::RESPONSE_DEFAULT.to_owned()),
2117            "an empty answer reads as the compiled default"
2118        );
2119        for value in [
2120            "0 days",
2121            "1 days",
2122            "2 day",
2123            "+2 days",
2124            "02 days",
2125            "4294967296 days",
2126            "90d",
2127            "two days",
2128            "we answer quickly",
2129            "2 weeks",
2130        ] {
2131            let refusal =
2132                super::canonical_response(value).expect_err("an unstateable window refuses");
2133            assert!(refusal.contains("security.response"), "{value}: {refusal}");
2134            assert!(refusal.contains("business days"), "{value}: {refusal}");
2135        }
2136        let refusal = parse("schema_version = 2\n[security]\nresponse = '90d'\n")
2137            .expect_err("the reader refuses it too")
2138            .to_string();
2139        assert!(refusal.contains("security.response"), "{refusal}");
2140    }
2141
2142    /// SATISFIES target-config:an-unanswered-key-is-absent-and-not-empty
2143    /// A header whose every key the writer omitted goes, and every comment
2144    /// it carried survives: standing above it, standing beside it, and in
2145    /// either position when the emptied table is the file's last.
2146    #[test]
2147    fn a_pruned_header_leaves_no_comment_behind() {
2148        let cases = [
2149            (
2150                "a middle table, comment above",
2151                "schema_version = 2\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2152            ),
2153            (
2154                "a middle table, comment inline",
2155                "schema_version = 2\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n\n[git]\ntrunk = \"main\"\n",
2156            ),
2157            (
2158                "the last table, comment above",
2159                "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n# the operator's note\n[project]\nrepo = \"acme/widget\"\n",
2160            ),
2161            (
2162                "the last table, comment inline",
2163                "schema_version = 2\n\n[git]\ntrunk = \"main\"\n\n[project] # the operator's note\nrepo = \"acme/widget\"\n",
2164            ),
2165        ];
2166        for (case, text) in cases {
2167            let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2168            assert!(!next.contains("repo ="), "{case}: {next}");
2169            assert!(
2170                !next.contains("[project]"),
2171                "{case}: a table every one of whose keys dropped goes with them: {next}"
2172            );
2173            assert!(
2174                next.contains("# the operator's note"),
2175                "{case}: the comment the header carried survives: {next}"
2176            );
2177            parse(&next).unwrap_or_else(|error| panic!("{case}: {error}"));
2178        }
2179    }
2180
2181    /// SATISFIES target-config:a-flag-overrides-and-a-landing-writes-back
2182    /// A key the writer retires takes the template's own comment with it
2183    /// and leaves the operator's behind, on the header of the domain the
2184    /// key belonged to. The template comment describes a key that is gone;
2185    /// the operator's comment is authored text this writer does not delete.
2186    #[test]
2187    fn a_retired_key_drops_the_template_comment_and_keeps_the_operators() {
2188        let text = concat!(
2189            "schema_version = 2\n\n[project]\n",
2190            "# the operator's note\n",
2191            "repo = \"acme/widget\" # P: project path on the forge\n\n",
2192            "[git]\ntrunk = \"main\"\n"
2193        );
2194        let next = super::rewrite_text(text, "project.repo", None).expect("the key removes");
2195        assert!(!next.contains("repo ="), "{next}");
2196        assert!(!next.contains("[project]"), "{next}");
2197        assert!(
2198            next.contains("# the operator's note"),
2199            "authored text survives: {next}"
2200        );
2201        assert!(
2202            !next.contains("# P:"),
2203            "the template's comment describes a key that is gone: {next}"
2204        );
2205        parse(&next).expect("the result parses");
2206
2207        // A domain that keeps other keys keeps the note on its own header.
2208        let text = concat!(
2209            "schema_version = 2\n\n[profile]\ntechnologies = [\"rust\"]\n\n",
2210            "[profile.release]\nmode = \"automatic\"\ndriver = \"rust\"\n",
2211            "# why this project names its own prefix\n",
2212            "line_prefix = \"stable/\"\n"
2213        );
2214        let next = super::rewrite_text(text, "profile.release.line_prefix", None)
2215            .expect("the key removes");
2216        assert!(!next.contains("line_prefix ="), "{next}");
2217        assert!(next.contains("[profile.release]"), "{next}");
2218        assert!(
2219            next.contains("# why this project names its own prefix"),
2220            "authored text survives: {next}"
2221        );
2222    }
2223}