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