Skip to main content

release_kit/
config.rs

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