Skip to main content

release_kit/
config.rs

1//! The committed target answers, parsed strictly and written from authored text.
2//! Comparisons continue to use the landing record alone.
3
4pub mod floors;
5
6use std::collections::BTreeMap;
7use std::fmt::Write as _;
8use std::path::Path;
9
10use crate::diagnostic::{Diagnostic, Reason};
11use crate::error::RkError;
12use crate::landing::{Style, Workflow};
13use serde::Deserialize;
14
15/// The committed input, relative to the target root.
16pub const CONFIG_PATH: &str = ".release-kit/config.toml";
17/// The only supported configuration schema.
18pub const SCHEMA_VERSION: i64 = 1;
19
20/// Per-target answers; an omitted table uses its compiled defaults.
21#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
22#[serde(deny_unknown_fields, default)]
23pub struct Config {
24    /// Version of the authored configuration shape.
25    pub schema_version: i64,
26    /// Landing identity and trunk name.
27    pub project: Project,
28    /// Values resolved into the landing record.
29    pub landing: Landing,
30    /// Report-routing facts, currently not rendered into any landed file.
31    pub security: Security,
32    /// Forge setup inputs.
33    pub setup: Setup,
34    /// Names and floored policy.
35    pub protection: Protection,
36}
37
38impl Default for Config {
39    fn default() -> Self {
40        Self {
41            schema_version: SCHEMA_VERSION,
42            project: Project::default(),
43            landing: Landing::default(),
44            security: Security::default(),
45            setup: Setup::default(),
46            protection: Protection::default(),
47        }
48    }
49}
50
51/// The `project` table.
52#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
53#[serde(deny_unknown_fields, default)]
54pub struct Project {
55    /// P: project path on the forge, nested groups included.
56    pub repo: String,
57    /// P: github or gitlab; empty means detect.
58    pub forge: String,
59    /// P: the binding; empty means detect.
60    pub tech: String,
61    /// P: the one permanent branch, rendered into every landed artifact
62    /// that names it. Absent means the landing has not answered it, so a
63    /// record's own answer survives an upgrade that predates the key.
64    pub trunk: Option<String>,
65}
66
67/// The compiled trunk, used where neither a configuration nor a record answers.
68pub const TRUNK_DEFAULT: &str = "master";
69
70/// The compiled release-line prefix, used where nothing else answers.
71pub const LINE_PREFIX_DEFAULT: &str = "release/";
72
73/// The `landing` table.
74#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
75#[serde(deny_unknown_fields, default)]
76pub struct Landing {
77    /// P: worktree or branches.
78    pub workflow: Option<Workflow>,
79    /// P: trunk or lines.
80    pub style: Option<Style>,
81    /// P: opt-in Nix capability.
82    pub nix: Option<bool>,
83    /// P: opt-in Scorecard capability.
84    pub scorecard: Option<bool>,
85    /// P: opt-in code scanning provider, as `codeql`, `semgrep`, or `off`.
86    /// The value stays a string here so an absent key and an explicit `off`
87    /// stay distinguishable; the landing parses it.
88    pub code_scanning: Option<String>,
89}
90
91/// The `security` table.
92#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
93#[serde(deny_unknown_fields, default)]
94pub struct Security {
95    /// N: project receiving vulnerability reports.
96    pub advisories: String,
97    /// P: contact when the forge channel is unavailable, rendered into the
98    /// landed policy. Absent means the landing has not answered it, so a
99    /// record's own answer survives an upgrade that predates the key; an
100    /// explicit empty string resets the policy to the forge's own prose.
101    pub contact: Option<String>,
102    /// P: the acknowledgment window the landed policy promises. Absent
103    /// means unanswered, exactly as `contact` does.
104    pub response: Option<String>,
105}
106
107/// The compiled response stance, used where nothing else answers: the
108/// policy promises no window at all.
109pub const RESPONSE_DEFAULT: &str = "best-effort";
110
111/// The canonical form of a security contact, or why it is refused.
112///
113/// One trimmed line. The value is rendered into `SECURITY.md` verbatim, so
114/// a line feed, a carriage return, or any other ASCII control character
115/// would break the sentence it lands in and is refused before any write.
116/// Emptiness is not a refusal: it selects the forge's own authored prose.
117///
118/// # Errors
119/// The refusal text, naming the key and what it accepts.
120pub fn canonical_contact(raw: &str) -> Result<String, String> {
121    let trimmed = raw.trim();
122    if trimmed.chars().any(char::is_control) {
123        return Err(format!(
124            "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:?}"
125        ));
126    }
127    Ok(trimmed.to_owned())
128}
129
130/// The canonical form of a response stance, or why it is refused.
131///
132/// Either `best-effort` or a plural-correct day count: `1 day`, `<n> days`,
133/// `1 business day`, or `<n> business days`, with `n` a `u32` above one
134/// written without a sign or a leading zero. The grammar is narrow because
135/// the rendered sentence is a public promise, and only a value this
136/// renderer can state exactly may reach it. An empty value reads as the
137/// compiled default.
138///
139/// # Errors
140/// The refusal text, naming the key and every accepted form.
141pub fn canonical_response(raw: &str) -> Result<String, String> {
142    let trimmed = raw.trim();
143    if trimmed.is_empty() || trimmed == RESPONSE_DEFAULT {
144        return Ok(RESPONSE_DEFAULT.to_owned());
145    }
146    let refusal = || {
147        format!(
148            "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:?}"
149        )
150    };
151    let (count, unit) = trimmed.split_once(' ').ok_or_else(refusal)?;
152    let plural = match unit {
153        "day" | "business day" => false,
154        "days" | "business days" => true,
155        _ => return Err(refusal()),
156    };
157    let number: u32 = count.parse().map_err(|_| refusal())?;
158    // A canonical count round-trips, which refuses a sign and a leading
159    // zero without a second pass over the text.
160    if count != number.to_string() || (number > 1) != plural {
161        return Err(refusal());
162    }
163    Ok(trimmed.to_owned())
164}
165
166/// The `setup` table.
167#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
168#[serde(deny_unknown_fields, default)]
169pub struct Setup {
170    /// N: the check the merge must pass.
171    pub required_check: String,
172    /// N: long-lived branches retired by the trunk.
173    pub retired_branches: Vec<String>,
174    /// P: release-line branch prefix, rendered into the release triggers
175    /// and branch guards a landing writes. Absent means unanswered, so a
176    /// record's own answer survives an upgrade that predates the key.
177    pub line_prefix: Option<String>,
178    /// N: run release-line protection in a full apply.
179    pub release_lines: bool,
180    /// N: the steps this target does not run, each against the reason a
181    /// report prints. An exclusion narrows what the setup judges and
182    /// weakens no floor: every value a step the target still runs reads is
183    /// floored exactly as before.
184    pub excluded_steps: BTreeMap<String, String>,
185    /// Public bot identity.
186    pub bot: Bot,
187}
188
189impl Default for Setup {
190    fn default() -> Self {
191        Self {
192            required_check: String::new(),
193            retired_branches: vec!["main".into(), "develop".into()],
194            line_prefix: None,
195            release_lines: false,
196            excluded_steps: BTreeMap::new(),
197            bot: Bot::default(),
198        }
199    }
200}
201
202/// The `setup.bot` table.
203///
204/// The App's public identifier and nothing else. The installation id is
205/// not here: it is the forge's own state, one cheap call answers it, and a
206/// cached copy that goes stale buys a refusal the operator must resolve by
207/// hand. The private key and the token are never here at all.
208#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
209#[serde(deny_unknown_fields, default)]
210pub struct Bot {
211    /// N: public App identifier; private credentials stay outside this file.
212    pub app_id: String,
213    /// Accepted and ignored. Version 0.3.13 wrote this key, so a target
214    /// landed by it must still parse; nothing reads the value and no new
215    /// configuration carries it. Removing it outright would refuse every
216    /// such target, because this reader denies an unknown key by design.
217    #[serde(default, skip_serializing)]
218    pub installation_id: Option<i64>,
219}
220
221/// The `protection` table.
222#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
223#[serde(deny_unknown_fields, default)]
224#[allow(
225    clippy::struct_excessive_bools,
226    reason = "these are independent policy switches in the committed TOML schema, not a state machine a smaller type could carry"
227)]
228pub struct Protection {
229    /// N: trunk ruleset name. Absent derives `<trunk>-protection`, which
230    /// is the name the setup script built before the key existed, so a
231    /// target that states none keeps the ruleset it already has.
232    pub trunk_ruleset: Option<String>,
233    /// N: tag ruleset name.
234    pub tag_ruleset: String,
235    /// N: release-line ruleset name.
236    pub lines_ruleset: String,
237    /// N: title job context.
238    pub title_check: String,
239    /// F: invariant, covers every published version.
240    pub tag_pattern: String,
241    /// F: invariant, empty.
242    pub bypass_actors: Vec<String>,
243    /// F: invariant, exactly squash.
244    pub allowed_merge_methods: Vec<String>,
245    /// F: invariant, true.
246    pub strict_required_status_checks: bool,
247    /// F: invariant, contains all four rules.
248    pub owned_trunk_rules: Vec<String>,
249    /// F: floor zero; higher is stricter.
250    pub required_approving_review_count: i64,
251    /// F: floor false; true is stricter.
252    pub dismiss_stale_reviews_on_push: bool,
253    /// F: floor false; true is stricter.
254    pub require_code_owner_review: bool,
255    /// F: floor false; true is stricter.
256    pub require_last_push_approval: bool,
257    /// GitHub policy.
258    pub github: Github,
259    /// GitLab policy.
260    pub gitlab: Gitlab,
261}
262
263impl Default for Protection {
264    fn default() -> Self {
265        Self {
266            trunk_ruleset: None,
267            tag_ruleset: "release-tags".into(),
268            lines_ruleset: "release-lines".into(),
269            title_check: "pr-title".into(),
270            tag_pattern: "refs/tags/v*".into(),
271            bypass_actors: Vec::new(),
272            allowed_merge_methods: vec!["squash".into()],
273            strict_required_status_checks: true,
274            owned_trunk_rules: vec![
275                "deletion".into(),
276                "non_fast_forward".into(),
277                "pull_request".into(),
278                "required_status_checks".into(),
279            ],
280            required_approving_review_count: 0,
281            dismiss_stale_reviews_on_push: false,
282            require_code_owner_review: false,
283            require_last_push_approval: false,
284            github: Github::default(),
285            gitlab: Gitlab::default(),
286        }
287    }
288}
289
290impl Protection {
291    /// The trunk ruleset's name: the target's own answer, or the name the
292    /// setup script derived before the key existed.
293    #[must_use]
294    pub fn trunk_ruleset(&self, trunk: &str) -> String {
295        self.trunk_ruleset
296            .clone()
297            .unwrap_or_else(|| format!("{trunk}-protection"))
298    }
299}
300
301/// The `protection.github` table.
302#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
303#[serde(deny_unknown_fields, default)]
304pub struct Github {
305    /// F: invariant, `PR_TITLE`.
306    pub squash_title_source: String,
307    /// F: invariant, `PR_BODY`.
308    pub squash_body_source: String,
309}
310
311impl Default for Github {
312    fn default() -> Self {
313        Self {
314            squash_title_source: "PR_TITLE".into(),
315            squash_body_source: "PR_BODY".into(),
316        }
317    }
318}
319
320/// The `protection.gitlab` table.
321#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
322#[serde(deny_unknown_fields, default)]
323pub struct Gitlab {
324    /// F: invariant, linear history.
325    pub merge_method: String,
326    /// F: invariant, always squash.
327    pub squash_option: String,
328    /// F: invariant, references title and description.
329    pub squash_commit_template: String,
330    /// F: invariant, zero.
331    pub push_access_level: i64,
332    /// F: floor thirty.
333    pub merge_access_level: i64,
334}
335
336impl Default for Gitlab {
337    fn default() -> Self {
338        Self {
339            merge_method: "ff".into(),
340            squash_option: "always".into(),
341            squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
342                .trim_end_matches('\n')
343                .to_owned(),
344            push_access_level: 0,
345            merge_access_level: 40,
346        }
347    }
348}
349
350/// Read the optional file; content errors refuse instead of falling back.
351///
352/// # Errors
353/// Returns a config-invalid refusal for invalid content, and preserves I/O errors.
354pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
355    let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
356        Ok(text) => text,
357        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
358        Err(error) => return Err(error.into()),
359    };
360    parse(&text).map(Some)
361}
362
363fn parse(text: &str) -> Result<Config, RkError> {
364    let raw: toml::Value =
365        toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
366    if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
367        return Err(invalid(format!("schema_version must be {SCHEMA_VERSION}")));
368    }
369    let config: Config = toml::from_str(text).map_err(|error: toml::de::Error| {
370        let mut message = error.to_string();
371        if let Some(rest) = error.message().strip_prefix("unknown field `") {
372            let names: Vec<_> = rest.split('`').collect();
373            if let Some(unknown) = names.first()
374                && let Some(nearest) = names
375                    .iter()
376                    .skip(2)
377                    .step_by(2)
378                    .min_by_key(|name| distance(unknown, name))
379            {
380                let _ = write!(message, "; nearest known key: {nearest}");
381            }
382        }
383        invalid(message)
384    })?;
385    if !config.project.forge.is_empty()
386        && crate::detect::Forge::parse(&config.project.forge).is_none()
387    {
388        return Err(invalid("project.forge must be github or gitlab"));
389    }
390    if !config.project.tech.is_empty()
391        && (config.project.tech.starts_with('_')
392            || crate::embedded::SNIPPETS
393                .get_dir(&config.project.tech)
394                .is_none())
395    {
396        return Err(invalid("project.tech must name a supported binding"));
397    }
398    if let Some(contact) = &config.security.contact {
399        canonical_contact(contact).map_err(invalid)?;
400    }
401    if let Some(response) = &config.security.response {
402        canonical_response(response).map_err(invalid)?;
403    }
404    exclusions(&config.setup.excluded_steps)?;
405    floors::check(&config)?;
406    Ok(config)
407}
408
409/// Judge the declared exclusions: every id names a step this binary runs,
410/// and every exclusion states why.
411///
412/// A reason is required because the exclusions are the audit trail. A
413/// reader must be able to tell a chosen subset from an incomplete setup,
414/// and a line that names a step and says nothing tells them neither.
415fn exclusions(excluded: &BTreeMap<String, String>) -> Result<(), RkError> {
416    for (name, reason) in excluded {
417        if crate::setup::steps::spec(name).is_none() {
418            let nearest = crate::setup::steps::STEPS
419                .iter()
420                .min_by_key(|step| distance(name, step.name))
421                .map_or("", |step| step.name);
422            return Err(invalid(format!(
423                "setup.excluded_steps names {name}, which is no setup step; nearest known step: {nearest}"
424            )));
425        }
426        if reason.trim().is_empty() {
427            return Err(invalid(format!(
428                "setup.excluded_steps names {name} with no reason; an excluded step is reported with why it is out of scope"
429            )));
430        }
431    }
432    Ok(())
433}
434
435pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
436    RkError::refusal(
437        Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
438            .action(format!("edit {CONFIG_PATH} and retry"))
439            .target_state("nothing was written"),
440    )
441}
442
443fn distance(left: &str, right: &str) -> usize {
444    let mut row: Vec<_> = (0..=right.chars().count()).collect();
445    for (i, a) in left.chars().enumerate() {
446        let mut previous = row[0];
447        row[0] = i + 1;
448        for (j, b) in right.chars().enumerate() {
449            let old = row[j + 1];
450            row[j + 1] = (previous + usize::from(a != b))
451                .min(row[j] + 1)
452                .min(old + 1);
453            previous = old;
454        }
455    }
456    row.last().copied().unwrap_or(0)
457}
458
459/// Write the authored template with TOML-escaped scalar substitutions.
460///
461/// # Errors
462/// Returns invalid configuration or I/O failures before or during the atomic write.
463pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
464    let bytes = render(config)?;
465    parse(&String::from_utf8_lossy(&bytes))?;
466    crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
467    Ok(())
468}
469
470fn array(values: &[String]) -> toml_edit::Value {
471    toml_edit::Value::Array(values.iter().collect())
472}
473
474/// The exclusions as the one-line inline table the template carries. A
475/// landing writes an empty one; an operator who wants several may turn it
476/// into a `[setup.excluded_steps]` table, which this reader parses the
477/// same way.
478fn inline(values: &BTreeMap<String, String>) -> toml_edit::Value {
479    let mut table = toml_edit::InlineTable::new();
480    for (key, value) in values {
481        table.insert(key, value.clone().into());
482    }
483    toml_edit::Value::InlineTable(table)
484}
485
486#[allow(
487    clippy::too_many_lines,
488    reason = "the render list is one token per authored line of the config template, and splitting it would hide that correspondence"
489)]
490fn render(config: &Config) -> Result<Vec<u8>, RkError> {
491    // The trunk names the ruleset the setup installs, so the written
492    // configuration states the name a target actually gets rather than a
493    // literal that would be wrong for any trunk but the default.
494    let trunk = config
495        .project
496        .trunk
497        .clone()
498        .ok_or_else(|| invalid("project.trunk is unresolved"))?;
499    let mut fields: Vec<(&str, toml_edit::Value)> = vec![
500        ("RK_CONFIG_SCHEMA_VERSION", config.schema_version.into()),
501        ("RK_CONFIG_PROJECT_REPO", config.project.repo.clone().into()),
502        (
503            "RK_CONFIG_PROJECT_FORGE",
504            config.project.forge.clone().into(),
505        ),
506        ("RK_CONFIG_PROJECT_TECH", config.project.tech.clone().into()),
507        ("RK_CONFIG_PROJECT_TRUNK", trunk.clone().into()),
508        (
509            "RK_CONFIG_LANDING_WORKFLOW",
510            config
511                .landing
512                .workflow
513                .ok_or_else(|| invalid("landing.workflow is unresolved"))?
514                .as_str()
515                .into(),
516        ),
517        (
518            "RK_CONFIG_LANDING_STYLE",
519            config
520                .landing
521                .style
522                .ok_or_else(|| invalid("landing.style is unresolved"))?
523                .as_str()
524                .into(),
525        ),
526        (
527            "RK_CONFIG_LANDING_NIX",
528            config
529                .landing
530                .nix
531                .ok_or_else(|| invalid("landing.nix is unresolved"))?
532                .into(),
533        ),
534        (
535            "RK_CONFIG_LANDING_SCORECARD",
536            config
537                .landing
538                .scorecard
539                .ok_or_else(|| invalid("landing.scorecard is unresolved"))?
540                .into(),
541        ),
542        (
543            "RK_CONFIG_LANDING_CODE_SCANNING",
544            config
545                .landing
546                .code_scanning
547                .clone()
548                .ok_or_else(|| invalid("landing.code_scanning is unresolved"))?
549                .into(),
550        ),
551        (
552            "RK_CONFIG_SECURITY_ADVISORIES",
553            config.security.advisories.clone().into(),
554        ),
555        (
556            "RK_CONFIG_SECURITY_CONTACT",
557            config.security.contact.clone().unwrap_or_default().into(),
558        ),
559        (
560            "RK_CONFIG_SECURITY_RESPONSE",
561            config
562                .security
563                .response
564                .clone()
565                .unwrap_or_else(|| RESPONSE_DEFAULT.to_owned())
566                .into(),
567        ),
568        (
569            "RK_CONFIG_SETUP_REQUIRED_CHECK",
570            config.setup.required_check.clone().into(),
571        ),
572        (
573            "RK_CONFIG_SETUP_RETIRED_BRANCHES",
574            array(&config.setup.retired_branches),
575        ),
576        (
577            "RK_CONFIG_SETUP_LINE_PREFIX",
578            config
579                .setup
580                .line_prefix
581                .clone()
582                .ok_or_else(|| invalid("setup.line_prefix is unresolved"))?
583                .into(),
584        ),
585        (
586            "RK_CONFIG_SETUP_RELEASE_LINES",
587            config.setup.release_lines.into(),
588        ),
589        (
590            "RK_CONFIG_SETUP_EXCLUDED_STEPS",
591            inline(&config.setup.excluded_steps),
592        ),
593        (
594            "RK_CONFIG_SETUP_BOT_APP_ID",
595            config.setup.bot.app_id.clone().into(),
596        ),
597    ];
598    fields.extend(protection_fields(&config.protection, trunk.as_str()));
599    let template = crate::embedded::BLOCKS
600        .get_file("target-config.toml.in")
601        .and_then(include_dir::File::contents_utf8)
602        .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
603    // Each authored line has one token. Substitute in the source line once,
604    // so a user's string containing another token stays literal.
605    let mut bytes = Vec::new();
606    for line in template.split_inclusive('\n') {
607        if let Some((token, value)) = fields.iter().find(|(token, _)| line.contains(token)) {
608            bytes.extend(crate::landing::substitute(
609                line.as_bytes(),
610                token.as_bytes(),
611                value.to_string().as_bytes(),
612            ));
613        } else {
614            bytes.extend_from_slice(line.as_bytes());
615        }
616    }
617    Ok(bytes)
618}
619
620fn protection_fields(
621    protection: &Protection,
622    trunk: &str,
623) -> Vec<(&'static str, toml_edit::Value)> {
624    vec![
625        (
626            "RK_CONFIG_PROTECTION_TRUNK_RULESET",
627            protection.trunk_ruleset(trunk).into(),
628        ),
629        (
630            "RK_CONFIG_PROTECTION_TAG_RULESET",
631            protection.tag_ruleset.clone().into(),
632        ),
633        (
634            "RK_CONFIG_PROTECTION_LINES_RULESET",
635            protection.lines_ruleset.clone().into(),
636        ),
637        (
638            "RK_CONFIG_PROTECTION_TITLE_CHECK",
639            protection.title_check.clone().into(),
640        ),
641        (
642            "RK_CONFIG_PROTECTION_TAG_PATTERN",
643            protection.tag_pattern.clone().into(),
644        ),
645        (
646            "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
647            array(&protection.bypass_actors),
648        ),
649        (
650            "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
651            array(&protection.allowed_merge_methods),
652        ),
653        (
654            "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
655            protection.strict_required_status_checks.into(),
656        ),
657        (
658            "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
659            array(&protection.owned_trunk_rules),
660        ),
661        (
662            "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
663            protection.required_approving_review_count.into(),
664        ),
665        (
666            "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
667            protection.dismiss_stale_reviews_on_push.into(),
668        ),
669        (
670            "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
671            protection.require_code_owner_review.into(),
672        ),
673        (
674            "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
675            protection.require_last_push_approval.into(),
676        ),
677        (
678            "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
679            protection.github.squash_title_source.clone().into(),
680        ),
681        (
682            "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
683            protection.github.squash_body_source.clone().into(),
684        ),
685        (
686            "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
687            protection.gitlab.merge_method.clone().into(),
688        ),
689        (
690            "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
691            protection.gitlab.squash_option.clone().into(),
692        ),
693        (
694            "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
695            protection.gitlab.squash_commit_template.clone().into(),
696        ),
697        (
698            "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
699            protection.gitlab.push_access_level.into(),
700        ),
701        (
702            "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
703            protection.gitlab.merge_access_level.into(),
704        ),
705    ]
706}
707
708/// Change one landing parameter while preserving comments and table ordering.
709///
710/// # Errors
711/// Refuses an invalid key, invalid resulting content, or unreadable file; writes atomically.
712pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
713    let path = target.join(CONFIG_PATH);
714    let text = std::fs::read_to_string(&path)?;
715    let next = rewrite_text(&text, key, value)?;
716    crate::atomic::write(&path, next.as_bytes())?;
717    Ok(())
718}
719
720fn rewrite_text(text: &str, key: &str, mut value: toml_edit::Value) -> Result<String, RkError> {
721    if ![
722        "project.repo",
723        "project.forge",
724        "project.tech",
725        "project.trunk",
726        "landing.workflow",
727        "landing.style",
728        "landing.nix",
729        "landing.scorecard",
730        "landing.code_scanning",
731        "security.contact",
732        "security.response",
733        "setup.line_prefix",
734    ]
735    .contains(&key)
736    {
737        return Err(invalid(format!("{key} is not a landing parameter")));
738    }
739    parse(text)?;
740    let mut document = text
741        .parse::<toml_edit::DocumentMut>()
742        .map_err(|error| invalid(error.to_string()))?;
743    let mut item = document.as_item_mut();
744    for segment in key.split('.') {
745        item = &mut item[segment];
746    }
747    if let Some(old) = item.as_value() {
748        if old
749            .as_str()
750            .zip(value.as_str())
751            .is_some_and(|(old, new)| old == new)
752            || old
753                .as_bool()
754                .zip(value.as_bool())
755                .is_some_and(|(old, new)| old == new)
756        {
757            return Ok(text.to_owned());
758        }
759        *value.decor_mut() = old.decor().clone();
760    }
761    *item = toml_edit::Item::Value(value);
762    let next = document.to_string();
763    parse(&next)?;
764    Ok(next)
765}
766
767/// Resolved landing input, including every key a preview would write.
768#[derive(Debug, Clone, serde::Serialize)]
769pub struct Plan {
770    /// Added or updated configuration.
771    pub action: &'static str,
772    /// Keys whose configured answers differ from the record.
773    pub changes: Vec<String>,
774    /// The exact authored TOML the apply writes.
775    pub content: String,
776}
777
778impl Plan {
779    /// Resolve the output without writing it; existing comments survive.
780    ///
781    /// # Errors
782    /// Propagates unreadable or invalid configuration.
783    pub fn new(
784        target: &Path,
785        params: &crate::landing::Params,
786        existing: Option<&Config>,
787        record: Option<&crate::landing::manifest::Manifest>,
788    ) -> Result<Self, RkError> {
789        let text = existing
790            .map(|_| std::fs::read_to_string(target.join(CONFIG_PATH)))
791            .transpose()?;
792        Self::compose(text.as_deref(), params, existing, record)
793    }
794
795    /// Resolve the output from the existing text already read, so a
796    /// planner that owns no filesystem can compose it from its
797    /// observation; existing comments survive.
798    ///
799    /// # Errors
800    /// Propagates invalid configuration.
801    pub fn compose(
802        text: Option<&str>,
803        params: &crate::landing::Params,
804        existing: Option<&Config>,
805        record: Option<&crate::landing::manifest::Manifest>,
806    ) -> Result<Self, RkError> {
807        let mut resolved = existing.cloned().unwrap_or_default();
808        resolved.project.tech = params.tech().into();
809        resolved.project.forge = params.forge().into();
810        resolved.project.repo = params.repo().into();
811        resolved.landing = Landing {
812            workflow: Some(params.workflow()),
813            style: params.style(),
814            nix: Some(params.nix()),
815            scorecard: Some(params.scorecard()),
816            code_scanning: Some(
817                params
818                    .code_scanning()
819                    .map_or("off", crate::landing::Provider::as_str)
820                    .to_owned(),
821            ),
822        };
823        resolved.project.trunk = Some(params.trunk().to_owned());
824        resolved.setup.line_prefix = Some(params.line_prefix().to_owned());
825        resolved.security.contact = Some(params.security_contact().to_owned());
826        resolved.security.response = Some(params.security_response().to_owned());
827        let content = if let Some(text) = text.filter(|_| existing.is_some()) {
828            let mut text = text.to_owned();
829            for (key, value) in parameter_values(&resolved) {
830                text = rewrite_text(&text, key, value)?;
831            }
832            text
833        } else {
834            String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
835        };
836        parse(&content)?;
837        Ok(Self {
838            action: if existing.is_some() {
839                "updated"
840            } else {
841                "added"
842            },
843            changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
844            content,
845        })
846    }
847
848    /// Write the prepared configuration before the landing record.
849    ///
850    /// # Errors
851    /// Propagates an atomic write failure.
852    pub fn apply(&self, target: &Path) -> Result<(), RkError> {
853        crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
854        Ok(())
855    }
856}
857
858fn parameter_values(config: &Config) -> Vec<(&'static str, toml_edit::Value)> {
859    let mut values = Vec::new();
860    for (key, value) in [
861        ("project.repo", &config.project.repo),
862        ("project.forge", &config.project.forge),
863        ("project.tech", &config.project.tech),
864    ] {
865        if !value.is_empty() {
866            values.push((key, value.clone().into()));
867        }
868    }
869    if let Some(value) = config.landing.workflow {
870        values.push(("landing.workflow", value.as_str().into()));
871    }
872    if let Some(value) = config.landing.style {
873        values.push(("landing.style", value.as_str().into()));
874    }
875    if let Some(value) = config.landing.nix {
876        values.push(("landing.nix", value.into()));
877    }
878    if let Some(value) = config.landing.scorecard {
879        values.push(("landing.scorecard", value.into()));
880    }
881    if let Some(value) = config.landing.code_scanning.clone() {
882        values.push(("landing.code_scanning", value.into()));
883    }
884    if let Some(value) = config.project.trunk.clone() {
885        values.push(("project.trunk", value.into()));
886    }
887    if let Some(value) = config.setup.line_prefix.clone() {
888        values.push(("setup.line_prefix", value.into()));
889    }
890    // An empty contact is an answer, not an absence: it resets the landed
891    // policy to the forge's own prose, so it projects like any other value.
892    if let Some(value) = config.security.contact.clone() {
893        values.push(("security.contact", value.into()));
894    }
895    if let Some(value) = config.security.response.clone() {
896        values.push(("security.response", value.into()));
897    }
898    values
899}
900
901/// Only explicit class P answers can be pending; comparisons still use the record.
902#[must_use]
903pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
904    let mut recorded = Config::default();
905    recorded.project.repo.clone_from(&record.parameters.repo);
906    recorded.project.forge.clone_from(&record.forge);
907    recorded.project.tech.clone_from(&record.tech);
908    recorded.landing = Landing {
909        workflow: Some(record.parameters.workflow),
910        style: record.parameters.style,
911        nix: Some(record.parameters.nix),
912        scorecard: Some(record.parameters.scorecard),
913        code_scanning: Some(
914            record
915                .parameters
916                .code_scanning
917                .map_or("off", crate::landing::Provider::as_str)
918                .to_owned(),
919        ),
920    };
921    recorded.project.trunk = Some(record.parameters.trunk.clone());
922    recorded.setup.line_prefix = Some(record.parameters.line_prefix.clone());
923    recorded.security.contact = Some(record.parameters.security_contact.clone());
924    recorded.security.response = Some(record.parameters.security_response.clone());
925    let baseline = parameter_values(&recorded);
926    parameter_values(config)
927        .into_iter()
928        .filter(|(key, value)| {
929            !baseline
930                .iter()
931                .any(|(other, old)| key == other && value.to_string() == old.to_string())
932        })
933        .map(|(key, _)| key.to_owned())
934        .collect()
935}
936
937/// The trunk accessor for callers without a setup context.
938///
939/// # Errors
940/// Propagates invalid configuration and I/O failures.
941pub fn trunk_of(target: &Path) -> Result<String, RkError> {
942    Ok(load(target)?
943        .and_then(|config| config.project.trunk)
944        .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
945}
946
947/// The release-line prefix for callers without a setup context.
948///
949/// # Errors
950/// Propagates invalid configuration and I/O failures.
951pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
952    Ok(load(target)?
953        .and_then(|config| config.setup.line_prefix)
954        .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
955}
956
957#[cfg(test)]
958mod tests {
959    use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
960    use crate::landing::{Style, Workflow};
961
962    #[test]
963    fn an_omitted_landing_key_is_distinguishable_from_an_explicit_default() {
964        let omitted = parse("schema_version = 1\n").expect("omitted answers parse");
965        let explicit = parse(
966            "schema_version = 1\n[landing]\nworkflow = 'worktree'\nstyle = 'trunk'\nnix = false\nscorecard = false\ncode_scanning = 'off'\n",
967        )
968        .expect("explicit defaults parse");
969        assert_eq!(omitted.landing, super::Landing::default());
970        assert_eq!(explicit.landing.workflow, Some(Workflow::Worktree));
971        assert_eq!(explicit.landing.style, Some(Style::Trunk));
972        assert_eq!(explicit.landing.nix, Some(false));
973        assert_eq!(explicit.landing.scorecard, Some(false));
974        assert_eq!(explicit.landing.code_scanning.as_deref(), Some("off"));
975        assert_ne!(omitted, explicit);
976    }
977
978    /// A configuration written by 0.3.13 carries `installation_id`, which
979    /// this version reads and ignores. Refusing it would strand every
980    /// target that release landed.
981    #[test]
982    fn a_config_from_the_release_that_wrote_installation_id_still_reads() {
983        let dir = tempfile::tempdir().expect("a tempdir");
984        std::fs::create_dir_all(dir.path().join(".release-kit")).expect("the directory exists");
985        std::fs::write(
986            dir.path().join(CONFIG_PATH),
987            "schema_version = 1\n\n[setup.bot]\napp_id = \"123\"\ninstallation_id = 0\n",
988        )
989        .expect("the config writes");
990        let held = load(dir.path())
991            .expect("the config reads")
992            .expect("it is present");
993        assert_eq!(held.setup.bot.app_id, "123");
994        assert_eq!(
995            held.setup.bot.installation_id,
996            Some(0),
997            "the key parses; nothing reads it"
998        );
999    }
1000
1001    #[test]
1002    fn the_landed_config_template_round_trips() {
1003        let dir = tempfile::tempdir().expect("a target exists");
1004        let mut config = Config::default();
1005        config.project.repo = "acme/nested/widget".into();
1006        config.project.forge = "gitlab".into();
1007        config.project.tech = "bash".into();
1008        config.project.trunk = Some("main".into());
1009        config.landing.workflow = Some(Workflow::Branches);
1010        config.landing.style = Some(Style::Lines);
1011        config.landing.nix = Some(true);
1012        config.landing.scorecard = Some(true);
1013        config.landing.code_scanning = Some("semgrep".to_owned());
1014        // The escaping subject moved to the one unrestricted string in this
1015        // table: `contact` is now a class P value the reader holds to a
1016        // single control-free line, so it can carry neither.
1017        config.security.advisories =
1018            "A \"quoted\" project\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
1019        config.security.contact = Some("security team, room 3 \"the vault\"".into());
1020        config.security.response = Some("14 business days".into());
1021        config.setup.required_check = "build / test".into();
1022        config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
1023        config.setup.line_prefix = Some("stable/".into());
1024        config.setup.release_lines = true;
1025        config.setup.excluded_steps = [
1026            (
1027                "package-check".to_owned(),
1028                "nothing is published".to_owned(),
1029            ),
1030            (
1031                "protect-trunk".to_owned(),
1032                "this project merges \"locally\"".to_owned(),
1033            ),
1034        ]
1035        .into_iter()
1036        .collect();
1037        config.setup.bot.app_id = "123".into();
1038        config.protection.trunk_ruleset = Some("primary".into());
1039        config.protection.tag_ruleset = "versions".into();
1040        config.protection.lines_ruleset = "maintenance".into();
1041        config.protection.title_check = "intent".into();
1042        config.protection.tag_pattern = "refs/tags/*".into();
1043        config
1044            .protection
1045            .owned_trunk_rules
1046            .push("required_signatures".into());
1047        config.protection.required_approving_review_count = 2;
1048        config.protection.dismiss_stale_reviews_on_push = true;
1049        config.protection.require_code_owner_review = true;
1050        config.protection.require_last_push_approval = true;
1051        config.protection.gitlab.squash_commit_template =
1052            "%{title}\n\nContext: %{description}".into();
1053        config.protection.gitlab.merge_access_level = 40;
1054        let defaults = Config {
1055            landing: super::Landing {
1056                workflow: Some(Workflow::Worktree),
1057                style: Some(Style::Trunk),
1058                nix: Some(false),
1059                scorecard: Some(false),
1060                code_scanning: Some("off".to_owned()),
1061            },
1062            project: super::Project {
1063                trunk: Some(super::TRUNK_DEFAULT.into()),
1064                ..super::Project::default()
1065            },
1066            setup: super::Setup {
1067                line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
1068                ..super::Setup::default()
1069            },
1070            // Writing states both security answers, so a reader sees the
1071            // policy the target landed rather than an implied one.
1072            security: super::Security {
1073                contact: Some(String::new()),
1074                response: Some(super::RESPONSE_DEFAULT.into()),
1075                ..super::Security::default()
1076            },
1077            // Writing resolves the derived ruleset name, so the file states
1078            // the name the setup installs rather than leaving it implied.
1079            protection: super::Protection {
1080                trunk_ruleset: Some(format!("{}-protection", super::TRUNK_DEFAULT)),
1081                ..super::Protection::default()
1082            },
1083            ..Config::default()
1084        };
1085        for expected in [defaults, config] {
1086            write(dir.path(), &expected).expect("the template renders");
1087            assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
1088            let text =
1089                std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
1090            assert!(text.contains("# P: project path"));
1091            assert!(text.contains("# F: invariant"));
1092        }
1093    }
1094
1095    #[test]
1096    fn a_config_with_an_unknown_key_refuses_by_name() {
1097        for (table, typo, nearest) in [
1098            ("", "schemax_version", "schema_version"),
1099            ("project", "trunkx", "trunk"),
1100            ("landing", "stile", "style"),
1101            ("security", "contactx", "contact"),
1102            ("setup", "required_checkx", "required_check"),
1103            ("setup.bot", "app_i", "app_id"),
1104            ("protection", "trunk_rulesett", "trunk_ruleset"),
1105            (
1106                "protection.github",
1107                "squash_body_sourcex",
1108                "squash_body_source",
1109            ),
1110            ("protection.gitlab", "squash_optionx", "squash_option"),
1111        ] {
1112            let header = if table.is_empty() {
1113                String::new()
1114            } else {
1115                format!("[{table}]\n")
1116            };
1117            let text = format!("schema_version = 1\n{header}{typo} = 'value'\n");
1118            let error = parse(&text).expect_err("unknown keys refuse").to_string();
1119            for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
1120                assert!(error.contains(expected), "{error}");
1121            }
1122        }
1123    }
1124
1125    /// An exclusion removes a step from scope, so a typo in one would
1126    /// silently keep judging a step the target does not run, and a
1127    /// reasonless one would leave a report nobody can audit.
1128    #[test]
1129    fn an_exclusion_names_a_real_step_and_states_why() {
1130        for (text, expected) in [
1131            (
1132                "[setup.excluded_steps]\nprotect-trunkk = 'we merge locally'\n",
1133                vec!["protect-trunkk", "nearest known step: protect-trunk"],
1134            ),
1135            (
1136                "[setup.excluded_steps]\nprotect-trunk = '  '\n",
1137                vec!["protect-trunk", "no reason"],
1138            ),
1139        ] {
1140            let error = parse(&format!("schema_version = 1\n{text}"))
1141                .expect_err("the exclusion refuses")
1142                .to_string();
1143            for want in expected {
1144                assert!(error.contains(want), "{error}");
1145            }
1146        }
1147        let held = parse(
1148            "schema_version = 1\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n",
1149        )
1150        .expect("a named step with a reason parses");
1151        assert_eq!(
1152            held.setup
1153                .excluded_steps
1154                .get("protect-trunk")
1155                .map(String::as_str),
1156            Some("we merge locally")
1157        );
1158    }
1159
1160    /// An exclusion narrows what the setup judges. It never weakens the
1161    /// method's policy, so the floors bind a target that runs a subset
1162    /// exactly as they bind one that runs every step.
1163    #[test]
1164    fn an_exclusion_does_not_lift_a_floor() {
1165        let error = parse(
1166            "schema_version = 1\n[setup.excluded_steps]\nprotect-trunk = 'we merge locally'\n\n[protection]\nallowed_merge_methods = ['squash', 'merge']\n",
1167        )
1168        .expect_err("the floor binds an excluded step's keys too")
1169        .to_string();
1170        assert!(
1171            error.contains("protection.allowed_merge_methods"),
1172            "{error}"
1173        );
1174    }
1175
1176    #[test]
1177    fn a_config_at_an_unknown_schema_refuses() {
1178        for text in ["schema_version = 999", "schema_version = '1'", ""] {
1179            let error = parse(text)
1180                .expect_err("a schema must be declared and known")
1181                .to_string();
1182            assert!(
1183                error.contains(CONFIG_PATH) && error.contains("schema_version"),
1184                "{error}"
1185            );
1186        }
1187    }
1188
1189    #[test]
1190    fn an_unparsable_config_refuses_naming_the_position() {
1191        let error = parse("schema_version = 1\n[project\n")
1192            .expect_err("bad TOML refuses")
1193            .to_string();
1194        for expected in [CONFIG_PATH, "line 2", "column"] {
1195            assert!(error.contains(expected), "{error}");
1196        }
1197    }
1198
1199    #[test]
1200    fn an_absent_config_reads_as_none() {
1201        let dir = tempfile::tempdir().expect("a target exists");
1202        assert_eq!(load(dir.path()).expect("absence is compatible"), None);
1203        assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
1204    }
1205
1206    #[test]
1207    fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
1208        let dir = tempfile::tempdir().expect("a target exists");
1209        std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1210        std::fs::write(
1211            dir.path().join(CONFIG_PATH),
1212            "schema_version = 1\n[protection]\nstrict_required_status_checks = false\n",
1213        )
1214        .expect("a config exists");
1215        let error =
1216            trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
1217        assert_eq!(error.exit_code(), 73);
1218        assert!(
1219            error
1220                .to_string()
1221                .contains("protection.strict_required_status_checks")
1222        );
1223    }
1224
1225    #[test]
1226    fn rewrite_key_preserves_comments() {
1227        let dir = tempfile::tempdir().expect("a target exists");
1228        std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
1229        let original = "# Project answers\nschema_version = 1\n\n[security] # first table stays first\ncontact = 'team' # keep me\n\n[landing]\n# Our release choice\nstyle  = 'trunk'  # keep this reason\nworkflow = 'branches'\n";
1230        let path = dir.path().join(CONFIG_PATH);
1231        std::fs::write(&path, original).expect("a config exists");
1232        rewrite_key(dir.path(), "landing.style", "lines".into()).expect("the style writes back");
1233        let text = std::fs::read_to_string(&path).expect("the text reads");
1234        assert_eq!(text, original.replace("'trunk'", "\"lines\""));
1235        assert_eq!(
1236            load(dir.path())
1237                .expect("the config reads")
1238                .expect("present")
1239                .landing
1240                .style,
1241            Some(Style::Lines)
1242        );
1243        rewrite_key(dir.path(), "project.repo", "acme/widget".into())
1244            .expect("an omitted table can be added");
1245        assert_eq!(
1246            load(dir.path())
1247                .expect("reads")
1248                .expect("present")
1249                .project
1250                .repo,
1251            "acme/widget"
1252        );
1253        rewrite_key(
1254            dir.path(),
1255            "security.contact",
1256            "security@acme.example".into(),
1257        )
1258        .expect("the contact is a landing parameter now");
1259        rewrite_key(dir.path(), "security.response", "14 days".into())
1260            .expect("the response is a landing parameter now");
1261        let held = load(dir.path()).expect("reads").expect("present");
1262        assert_eq!(
1263            held.security.contact.as_deref(),
1264            Some("security@acme.example")
1265        );
1266        assert_eq!(held.security.response.as_deref(), Some("14 days"));
1267        let text = std::fs::read_to_string(&path).expect("the text reads");
1268        assert!(text.contains("# keep me"), "the comment survives: {text}");
1269        let before = std::fs::read(&path).expect("the bytes read");
1270        for (key, value) in [
1271            ("security.advisories", "acme/private"),
1272            ("security.response", "90d"),
1273            ("landing.style", "unknown"),
1274        ] {
1275            assert!(rewrite_key(dir.path(), key, value.into()).is_err());
1276            assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
1277        }
1278    }
1279
1280    /// The two security answers are one line and one narrow grammar,
1281    /// because both land verbatim in a public policy.
1282    #[test]
1283    fn the_security_answers_are_held_to_their_grammar() {
1284        for value in ["team@acme.example", "  https://acme.example/report  ", ""] {
1285            super::canonical_contact(value).expect("a control-free line is a contact");
1286        }
1287        for value in ["one\ntwo", "one\rtwo", "one\u{7}two"] {
1288            let refusal = super::canonical_contact(value).expect_err("a control character refuses");
1289            assert!(refusal.contains("security.contact"), "{refusal}");
1290        }
1291        assert_eq!(
1292            super::canonical_contact("  team@acme.example  "),
1293            Ok("team@acme.example".to_owned()),
1294            "surrounding whitespace is trimmed"
1295        );
1296        for value in [
1297            "best-effort",
1298            "1 day",
1299            "2 days",
1300            "14 days",
1301            "1 business day",
1302            "14 business days",
1303        ] {
1304            assert_eq!(super::canonical_response(value), Ok(value.to_owned()));
1305        }
1306        assert_eq!(
1307            super::canonical_response(""),
1308            Ok(super::RESPONSE_DEFAULT.to_owned()),
1309            "an empty answer reads as the compiled default"
1310        );
1311        for value in [
1312            "0 days",
1313            "1 days",
1314            "2 day",
1315            "+2 days",
1316            "02 days",
1317            "4294967296 days",
1318            "90d",
1319            "two days",
1320            "we answer quickly",
1321            "2 weeks",
1322        ] {
1323            let refusal =
1324                super::canonical_response(value).expect_err("an unstateable window refuses");
1325            assert!(refusal.contains("security.response"), "{value}: {refusal}");
1326            assert!(refusal.contains("business days"), "{value}: {refusal}");
1327        }
1328        let refusal = parse("schema_version = 1\n[security]\nresponse = '90d'\n")
1329            .expect_err("the reader refuses it too")
1330            .to_string();
1331        assert!(refusal.contains("security.response"), "{refusal}");
1332    }
1333}