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