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