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