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::fmt::Write as _;
7use std::path::Path;
8
9use crate::diagnostic::{Diagnostic, Reason};
10use crate::error::RkError;
11use crate::landing::{Style, Workflow};
12use serde::Deserialize;
13
14/// The committed input, relative to the target root.
15pub const CONFIG_PATH: &str = ".release-kit/config.toml";
16/// The only supported configuration schema.
17pub const SCHEMA_VERSION: i64 = 1;
18
19/// Per-target answers; an omitted table uses its compiled defaults.
20#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
21#[serde(deny_unknown_fields, default)]
22pub struct Config {
23    /// Version of the authored configuration shape.
24    pub schema_version: i64,
25    /// Landing identity and trunk name.
26    pub project: Project,
27    /// Values resolved into the landing record.
28    pub landing: Landing,
29    /// Report-routing facts, currently not rendered into any payload.
30    pub security: Security,
31    /// Forge setup inputs.
32    pub setup: Setup,
33    /// Names and floored policy.
34    pub protection: Protection,
35}
36
37impl Default for Config {
38    fn default() -> Self {
39        Self {
40            schema_version: SCHEMA_VERSION,
41            project: Project::default(),
42            landing: Landing::default(),
43            security: Security::default(),
44            setup: Setup::default(),
45            protection: Protection::default(),
46        }
47    }
48}
49
50/// The `project` table.
51#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
52#[serde(deny_unknown_fields, default)]
53pub struct Project {
54    /// P: project path on the forge, nested groups included.
55    pub repo: String,
56    /// P: github or gitlab; empty means detect.
57    pub forge: String,
58    /// P: payload binding; empty means detect.
59    pub tech: String,
60    /// P: the one permanent branch, rendered into every landed artifact
61    /// that names it. Absent means the landing has not answered it, so a
62    /// record's own answer survives an upgrade that predates the key.
63    pub trunk: Option<String>,
64}
65
66/// The compiled trunk, used where neither a configuration nor a record answers.
67pub const TRUNK_DEFAULT: &str = "master";
68
69/// The compiled release-line prefix, used where nothing else answers.
70pub const LINE_PREFIX_DEFAULT: &str = "release/";
71
72/// The `landing` table.
73#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
74#[serde(deny_unknown_fields, default)]
75pub struct Landing {
76    /// P: worktree or branches.
77    pub workflow: Option<Workflow>,
78    /// P: trunk or lines.
79    pub style: Option<Style>,
80    /// P: opt-in Nix capability.
81    pub nix: Option<bool>,
82}
83
84/// The `security` table.
85#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
86#[serde(deny_unknown_fields, default)]
87pub struct Security {
88    /// N: project receiving vulnerability reports.
89    pub advisories: String,
90    /// N: contact when the form is unavailable.
91    pub contact: String,
92    /// N: best-effort or a response window.
93    pub response: String,
94}
95
96impl Default for Security {
97    fn default() -> Self {
98        Self {
99            advisories: String::new(),
100            contact: String::new(),
101            response: "best-effort".into(),
102        }
103    }
104}
105
106/// The `setup` table.
107#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
108#[serde(deny_unknown_fields, default)]
109pub struct Setup {
110    /// N: the check the merge must pass.
111    pub required_check: String,
112    /// N: long-lived branches retired by the trunk.
113    pub retired_branches: Vec<String>,
114    /// P: release-line branch prefix, rendered into the release triggers
115    /// and branch guards a landing writes. Absent means unanswered, so a
116    /// record's own answer survives an upgrade that predates the key.
117    pub line_prefix: Option<String>,
118    /// N: run release-line protection in a full apply.
119    pub release_lines: bool,
120    /// Public bot identity.
121    pub bot: Bot,
122}
123
124impl Default for Setup {
125    fn default() -> Self {
126        Self {
127            required_check: String::new(),
128            retired_branches: vec!["main".into(), "develop".into()],
129            line_prefix: None,
130            release_lines: false,
131            bot: Bot::default(),
132        }
133    }
134}
135
136/// The `setup.bot` table.
137#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
138#[serde(deny_unknown_fields, default)]
139pub struct Bot {
140    /// N: public App identifier; private credentials stay outside this file.
141    pub app_id: String,
142    /// N: verified cache; zero means discover.
143    pub installation_id: i64,
144}
145
146/// The `protection` table.
147#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
148#[serde(deny_unknown_fields, default)]
149// These are independent policy switches in the committed TOML schema.
150#[allow(clippy::struct_excessive_bools)]
151pub struct Protection {
152    /// N: trunk ruleset name.
153    pub trunk_ruleset: String,
154    /// N: tag ruleset name.
155    pub tag_ruleset: String,
156    /// N: release-line ruleset name.
157    pub lines_ruleset: String,
158    /// N: title job context.
159    pub title_check: String,
160    /// F: invariant, covers every published version.
161    pub tag_pattern: String,
162    /// F: invariant, empty.
163    pub bypass_actors: Vec<String>,
164    /// F: invariant, exactly squash.
165    pub allowed_merge_methods: Vec<String>,
166    /// F: invariant, true.
167    pub strict_required_status_checks: bool,
168    /// F: invariant, contains all four rules.
169    pub owned_trunk_rules: Vec<String>,
170    /// F: floor zero; higher is stricter.
171    pub required_approving_review_count: i64,
172    /// F: floor false; true is stricter.
173    pub dismiss_stale_reviews_on_push: bool,
174    /// F: floor false; true is stricter.
175    pub require_code_owner_review: bool,
176    /// F: floor false; true is stricter.
177    pub require_last_push_approval: bool,
178    /// GitHub policy.
179    pub github: Github,
180    /// GitLab policy.
181    pub gitlab: Gitlab,
182}
183
184impl Default for Protection {
185    fn default() -> Self {
186        Self {
187            trunk_ruleset: "master-protection".into(),
188            tag_ruleset: "release-tags".into(),
189            lines_ruleset: "release-lines".into(),
190            title_check: "pr-title".into(),
191            tag_pattern: "refs/tags/v*".into(),
192            bypass_actors: Vec::new(),
193            allowed_merge_methods: vec!["squash".into()],
194            strict_required_status_checks: true,
195            owned_trunk_rules: vec![
196                "deletion".into(),
197                "non_fast_forward".into(),
198                "pull_request".into(),
199                "required_status_checks".into(),
200            ],
201            required_approving_review_count: 0,
202            dismiss_stale_reviews_on_push: false,
203            require_code_owner_review: false,
204            require_last_push_approval: false,
205            github: Github::default(),
206            gitlab: Gitlab::default(),
207        }
208    }
209}
210
211/// The `protection.github` table.
212#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
213#[serde(deny_unknown_fields, default)]
214pub struct Github {
215    /// F: invariant, `PR_TITLE`.
216    pub squash_title_source: String,
217    /// F: invariant, `PR_BODY`.
218    pub squash_body_source: String,
219}
220
221impl Default for Github {
222    fn default() -> Self {
223        Self {
224            squash_title_source: "PR_TITLE".into(),
225            squash_body_source: "PR_BODY".into(),
226        }
227    }
228}
229
230/// The `protection.gitlab` table.
231#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
232#[serde(deny_unknown_fields, default)]
233pub struct Gitlab {
234    /// F: invariant, linear history.
235    pub merge_method: String,
236    /// F: invariant, always squash.
237    pub squash_option: String,
238    /// F: invariant, references title and description.
239    pub squash_commit_template: String,
240    /// F: invariant, zero.
241    pub push_access_level: i64,
242    /// F: floor thirty.
243    pub merge_access_level: i64,
244}
245
246impl Default for Gitlab {
247    fn default() -> Self {
248        Self {
249            merge_method: "ff".into(),
250            squash_option: "always".into(),
251            squash_commit_template: include_str!("../blocks/gitlab-squash-commit-template.in")
252                .trim_end_matches('\n')
253                .to_owned(),
254            push_access_level: 0,
255            merge_access_level: 30,
256        }
257    }
258}
259
260/// Read the optional file; content errors refuse instead of falling back.
261///
262/// # Errors
263/// Returns a config-invalid refusal for invalid content, and preserves I/O errors.
264pub fn load(target: &Path) -> Result<Option<Config>, RkError> {
265    let text = match std::fs::read_to_string(target.join(CONFIG_PATH)) {
266        Ok(text) => text,
267        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
268        Err(error) => return Err(error.into()),
269    };
270    parse(&text).map(Some)
271}
272
273fn parse(text: &str) -> Result<Config, RkError> {
274    let raw: toml::Value =
275        toml::from_str(text).map_err(|error: toml::de::Error| invalid(error.to_string()))?;
276    if raw.get("schema_version").and_then(toml::Value::as_integer) != Some(SCHEMA_VERSION) {
277        return Err(invalid(format!("schema_version must be {SCHEMA_VERSION}")));
278    }
279    let config: Config = toml::from_str(text).map_err(|error: toml::de::Error| {
280        let mut message = error.to_string();
281        if let Some(rest) = error.message().strip_prefix("unknown field `") {
282            let names: Vec<_> = rest.split('`').collect();
283            if let Some(unknown) = names.first() {
284                if let Some(nearest) = names
285                    .iter()
286                    .skip(2)
287                    .step_by(2)
288                    .min_by_key(|name| distance(unknown, name))
289                {
290                    let _ = write!(message, "; nearest known key: {nearest}");
291                }
292            }
293        }
294        invalid(message)
295    })?;
296    if !config.project.forge.is_empty()
297        && crate::detect::Forge::parse(&config.project.forge).is_none()
298    {
299        return Err(invalid("project.forge must be github or gitlab"));
300    }
301    if !config.project.tech.is_empty()
302        && (config.project.tech.starts_with('_')
303            || crate::embedded::SNIPPETS
304                .get_dir(&config.project.tech)
305                .is_none())
306    {
307        return Err(invalid(
308            "project.tech must name a supported payload binding",
309        ));
310    }
311    floors::check(&config)?;
312    Ok(config)
313}
314
315pub(super) fn invalid(message: impl std::fmt::Display) -> RkError {
316    RkError::refusal(
317        Diagnostic::new(Reason::ConfigInvalid, format!("{CONFIG_PATH}: {message}"))
318            .action(format!("edit {CONFIG_PATH} and retry"))
319            .target_state("nothing was written"),
320    )
321}
322
323fn distance(left: &str, right: &str) -> usize {
324    let mut row: Vec<_> = (0..=right.chars().count()).collect();
325    for (i, a) in left.chars().enumerate() {
326        let mut previous = row[0];
327        row[0] = i + 1;
328        for (j, b) in right.chars().enumerate() {
329            let old = row[j + 1];
330            row[j + 1] = (previous + usize::from(a != b))
331                .min(row[j] + 1)
332                .min(old + 1);
333            previous = old;
334        }
335    }
336    row.last().copied().unwrap_or(0)
337}
338
339/// Write the authored template with TOML-escaped scalar substitutions.
340///
341/// # Errors
342/// Returns invalid configuration or I/O failures before or during the atomic write.
343pub fn write(target: &Path, config: &Config) -> Result<(), RkError> {
344    let bytes = render(config)?;
345    parse(&String::from_utf8_lossy(&bytes))?;
346    crate::atomic::write(&target.join(CONFIG_PATH), &bytes)?;
347    Ok(())
348}
349
350fn array(values: &[String]) -> toml_edit::Value {
351    toml_edit::Value::Array(values.iter().collect())
352}
353
354#[allow(clippy::too_many_lines)]
355fn render(config: &Config) -> Result<Vec<u8>, RkError> {
356    let mut fields: Vec<(&str, toml_edit::Value)> = vec![
357        ("RK_CONFIG_SCHEMA_VERSION", config.schema_version.into()),
358        ("RK_CONFIG_PROJECT_REPO", config.project.repo.clone().into()),
359        (
360            "RK_CONFIG_PROJECT_FORGE",
361            config.project.forge.clone().into(),
362        ),
363        ("RK_CONFIG_PROJECT_TECH", config.project.tech.clone().into()),
364        (
365            "RK_CONFIG_PROJECT_TRUNK",
366            config
367                .project
368                .trunk
369                .clone()
370                .ok_or_else(|| invalid("project.trunk is unresolved"))?
371                .into(),
372        ),
373        (
374            "RK_CONFIG_LANDING_WORKFLOW",
375            config
376                .landing
377                .workflow
378                .ok_or_else(|| invalid("landing.workflow is unresolved"))?
379                .as_str()
380                .into(),
381        ),
382        (
383            "RK_CONFIG_LANDING_STYLE",
384            config
385                .landing
386                .style
387                .ok_or_else(|| invalid("landing.style is unresolved"))?
388                .as_str()
389                .into(),
390        ),
391        (
392            "RK_CONFIG_LANDING_NIX",
393            config
394                .landing
395                .nix
396                .ok_or_else(|| invalid("landing.nix is unresolved"))?
397                .into(),
398        ),
399        (
400            "RK_CONFIG_SECURITY_ADVISORIES",
401            config.security.advisories.clone().into(),
402        ),
403        (
404            "RK_CONFIG_SECURITY_CONTACT",
405            config.security.contact.clone().into(),
406        ),
407        (
408            "RK_CONFIG_SECURITY_RESPONSE",
409            config.security.response.clone().into(),
410        ),
411        (
412            "RK_CONFIG_SETUP_REQUIRED_CHECK",
413            config.setup.required_check.clone().into(),
414        ),
415        (
416            "RK_CONFIG_SETUP_RETIRED_BRANCHES",
417            array(&config.setup.retired_branches),
418        ),
419        (
420            "RK_CONFIG_SETUP_LINE_PREFIX",
421            config
422                .setup
423                .line_prefix
424                .clone()
425                .ok_or_else(|| invalid("setup.line_prefix is unresolved"))?
426                .into(),
427        ),
428        (
429            "RK_CONFIG_SETUP_RELEASE_LINES",
430            config.setup.release_lines.into(),
431        ),
432        (
433            "RK_CONFIG_SETUP_BOT_APP_ID",
434            config.setup.bot.app_id.clone().into(),
435        ),
436        (
437            "RK_CONFIG_SETUP_BOT_INSTALLATION_ID",
438            config.setup.bot.installation_id.into(),
439        ),
440    ];
441    fields.extend(protection_fields(&config.protection));
442    let template = crate::embedded::BLOCKS
443        .get_file("target-config.toml.in")
444        .and_then(include_dir::File::contents_utf8)
445        .ok_or_else(|| invalid("the binary lacks its configuration template"))?;
446    // Each authored line has one token. Substitute in the source line once,
447    // so a user's string containing another token stays literal.
448    let mut bytes = Vec::new();
449    for line in template.split_inclusive('\n') {
450        if let Some((token, value)) = fields.iter().find(|(token, _)| line.contains(token)) {
451            bytes.extend(crate::landing::substitute(
452                line.as_bytes(),
453                token.as_bytes(),
454                value.to_string().as_bytes(),
455            ));
456        } else {
457            bytes.extend_from_slice(line.as_bytes());
458        }
459    }
460    Ok(bytes)
461}
462
463fn protection_fields(protection: &Protection) -> Vec<(&'static str, toml_edit::Value)> {
464    vec![
465        (
466            "RK_CONFIG_PROTECTION_TRUNK_RULESET",
467            protection.trunk_ruleset.clone().into(),
468        ),
469        (
470            "RK_CONFIG_PROTECTION_TAG_RULESET",
471            protection.tag_ruleset.clone().into(),
472        ),
473        (
474            "RK_CONFIG_PROTECTION_LINES_RULESET",
475            protection.lines_ruleset.clone().into(),
476        ),
477        (
478            "RK_CONFIG_PROTECTION_TITLE_CHECK",
479            protection.title_check.clone().into(),
480        ),
481        (
482            "RK_CONFIG_PROTECTION_TAG_PATTERN",
483            protection.tag_pattern.clone().into(),
484        ),
485        (
486            "RK_CONFIG_PROTECTION_BYPASS_ACTORS",
487            array(&protection.bypass_actors),
488        ),
489        (
490            "RK_CONFIG_PROTECTION_ALLOWED_MERGE_METHODS",
491            array(&protection.allowed_merge_methods),
492        ),
493        (
494            "RK_CONFIG_PROTECTION_STRICT_REQUIRED_STATUS_CHECKS",
495            protection.strict_required_status_checks.into(),
496        ),
497        (
498            "RK_CONFIG_PROTECTION_OWNED_TRUNK_RULES",
499            array(&protection.owned_trunk_rules),
500        ),
501        (
502            "RK_CONFIG_PROTECTION_REQUIRED_APPROVING_REVIEW_COUNT",
503            protection.required_approving_review_count.into(),
504        ),
505        (
506            "RK_CONFIG_PROTECTION_DISMISS_STALE_REVIEWS_ON_PUSH",
507            protection.dismiss_stale_reviews_on_push.into(),
508        ),
509        (
510            "RK_CONFIG_PROTECTION_REQUIRE_CODE_OWNER_REVIEW",
511            protection.require_code_owner_review.into(),
512        ),
513        (
514            "RK_CONFIG_PROTECTION_REQUIRE_LAST_PUSH_APPROVAL",
515            protection.require_last_push_approval.into(),
516        ),
517        (
518            "RK_CONFIG_PROTECTION_GITHUB_SQUASH_TITLE_SOURCE",
519            protection.github.squash_title_source.clone().into(),
520        ),
521        (
522            "RK_CONFIG_PROTECTION_GITHUB_SQUASH_BODY_SOURCE",
523            protection.github.squash_body_source.clone().into(),
524        ),
525        (
526            "RK_CONFIG_PROTECTION_GITLAB_MERGE_METHOD",
527            protection.gitlab.merge_method.clone().into(),
528        ),
529        (
530            "RK_CONFIG_PROTECTION_GITLAB_SQUASH_OPTION",
531            protection.gitlab.squash_option.clone().into(),
532        ),
533        (
534            "RK_CONFIG_PROTECTION_GITLAB_SQUASH_COMMIT_TEMPLATE",
535            protection.gitlab.squash_commit_template.clone().into(),
536        ),
537        (
538            "RK_CONFIG_PROTECTION_GITLAB_PUSH_ACCESS_LEVEL",
539            protection.gitlab.push_access_level.into(),
540        ),
541        (
542            "RK_CONFIG_PROTECTION_GITLAB_MERGE_ACCESS_LEVEL",
543            protection.gitlab.merge_access_level.into(),
544        ),
545    ]
546}
547
548/// Change one landing parameter while preserving comments and table ordering.
549///
550/// # Errors
551/// Refuses an invalid key, invalid resulting content, or unreadable file; writes atomically.
552pub fn rewrite_key(target: &Path, key: &str, value: toml_edit::Value) -> Result<(), RkError> {
553    let path = target.join(CONFIG_PATH);
554    let text = std::fs::read_to_string(&path)?;
555    let next = rewrite_text(&text, key, value)?;
556    crate::atomic::write(&path, next.as_bytes())?;
557    Ok(())
558}
559
560fn rewrite_text(text: &str, key: &str, mut value: toml_edit::Value) -> Result<String, RkError> {
561    if ![
562        "project.repo",
563        "project.forge",
564        "project.tech",
565        "project.trunk",
566        "landing.workflow",
567        "landing.style",
568        "landing.nix",
569        "setup.line_prefix",
570    ]
571    .contains(&key)
572    {
573        return Err(invalid(format!("{key} is not a landing parameter")));
574    }
575    parse(text)?;
576    let mut document = text
577        .parse::<toml_edit::DocumentMut>()
578        .map_err(|error| invalid(error.to_string()))?;
579    let mut item = document.as_item_mut();
580    for segment in key.split('.') {
581        item = &mut item[segment];
582    }
583    if let Some(old) = item.as_value() {
584        if old
585            .as_str()
586            .zip(value.as_str())
587            .is_some_and(|(old, new)| old == new)
588            || old
589                .as_bool()
590                .zip(value.as_bool())
591                .is_some_and(|(old, new)| old == new)
592        {
593            return Ok(text.to_owned());
594        }
595        *value.decor_mut() = old.decor().clone();
596    }
597    *item = toml_edit::Item::Value(value);
598    let next = document.to_string();
599    parse(&next)?;
600    Ok(next)
601}
602
603/// Resolved landing input, including every key a preview would write.
604#[derive(Debug, serde::Serialize)]
605pub struct Plan {
606    /// Added or updated configuration.
607    pub action: &'static str,
608    /// Keys whose configured answers differ from the record.
609    pub changes: Vec<String>,
610    /// The exact authored TOML the apply writes.
611    pub content: String,
612}
613
614impl Plan {
615    /// Resolve the output without writing it; existing comments survive.
616    ///
617    /// # Errors
618    /// Propagates unreadable or invalid configuration.
619    pub fn new(
620        target: &Path,
621        params: &crate::landing::Params,
622        existing: Option<&Config>,
623        record: Option<&crate::landing::manifest::Manifest>,
624    ) -> Result<Self, RkError> {
625        let mut resolved = existing.cloned().unwrap_or_default();
626        resolved.project.tech = params.tech().into();
627        resolved.project.forge = params.forge().into();
628        resolved.project.repo = params.repo().into();
629        resolved.landing = Landing {
630            workflow: Some(params.workflow()),
631            style: params.style(),
632            nix: Some(params.nix()),
633        };
634        resolved.project.trunk = Some(params.trunk().to_owned());
635        resolved.setup.line_prefix = Some(params.line_prefix().to_owned());
636        let content = if existing.is_some() {
637            let mut text = std::fs::read_to_string(target.join(CONFIG_PATH))?;
638            for (key, value) in parameter_values(&resolved) {
639                text = rewrite_text(&text, key, value)?;
640            }
641            text
642        } else {
643            String::from_utf8(render(&resolved)?).map_err(|e| invalid(e.to_string()))?
644        };
645        parse(&content)?;
646        Ok(Self {
647            action: if existing.is_some() {
648                "updated"
649            } else {
650                "added"
651            },
652            changes: record.map_or_else(Vec::new, |record| pending(&resolved, record)),
653            content,
654        })
655    }
656
657    /// Write the prepared configuration before the landing record.
658    ///
659    /// # Errors
660    /// Propagates an atomic write failure.
661    pub fn apply(&self, target: &Path) -> Result<(), RkError> {
662        crate::atomic::write(&target.join(CONFIG_PATH), self.content.as_bytes())?;
663        Ok(())
664    }
665}
666
667fn parameter_values(config: &Config) -> Vec<(&'static str, toml_edit::Value)> {
668    let mut values = Vec::new();
669    for (key, value) in [
670        ("project.repo", &config.project.repo),
671        ("project.forge", &config.project.forge),
672        ("project.tech", &config.project.tech),
673    ] {
674        if !value.is_empty() {
675            values.push((key, value.clone().into()));
676        }
677    }
678    if let Some(value) = config.landing.workflow {
679        values.push(("landing.workflow", value.as_str().into()));
680    }
681    if let Some(value) = config.landing.style {
682        values.push(("landing.style", value.as_str().into()));
683    }
684    if let Some(value) = config.landing.nix {
685        values.push(("landing.nix", value.into()));
686    }
687    if let Some(value) = config.project.trunk.clone() {
688        values.push(("project.trunk", value.into()));
689    }
690    if let Some(value) = config.setup.line_prefix.clone() {
691        values.push(("setup.line_prefix", value.into()));
692    }
693    values
694}
695
696/// Only explicit class P answers can be pending; comparisons still use the record.
697#[must_use]
698pub fn pending(config: &Config, record: &crate::landing::manifest::Manifest) -> Vec<String> {
699    let mut recorded = Config::default();
700    recorded.project.repo.clone_from(&record.parameters.repo);
701    recorded.project.forge.clone_from(&record.forge);
702    recorded.project.tech.clone_from(&record.tech);
703    recorded.landing = Landing {
704        workflow: Some(record.parameters.workflow),
705        style: record.parameters.style,
706        nix: Some(record.parameters.nix),
707    };
708    recorded.project.trunk = Some(record.parameters.trunk.clone());
709    recorded.setup.line_prefix = Some(record.parameters.line_prefix.clone());
710    let baseline = parameter_values(&recorded);
711    parameter_values(config)
712        .into_iter()
713        .filter(|(key, value)| {
714            !baseline
715                .iter()
716                .any(|(other, old)| key == other && value.to_string() == old.to_string())
717        })
718        .map(|(key, _)| key.to_owned())
719        .collect()
720}
721
722/// The trunk accessor for callers without a setup context.
723///
724/// # Errors
725/// Propagates invalid configuration and I/O failures.
726pub fn trunk_of(target: &Path) -> Result<String, RkError> {
727    Ok(load(target)?
728        .and_then(|config| config.project.trunk)
729        .unwrap_or_else(|| TRUNK_DEFAULT.to_owned()))
730}
731
732/// The release-line prefix for callers without a setup context.
733///
734/// # Errors
735/// Propagates invalid configuration and I/O failures.
736pub fn line_prefix_of(target: &Path) -> Result<String, RkError> {
737    Ok(load(target)?
738        .and_then(|config| config.setup.line_prefix)
739        .unwrap_or_else(|| LINE_PREFIX_DEFAULT.to_owned()))
740}
741
742#[cfg(test)]
743mod tests {
744    #![allow(clippy::expect_used)]
745
746    use super::{CONFIG_PATH, Config, load, parse, rewrite_key, trunk_of, write};
747    use crate::landing::{Style, Workflow};
748
749    #[test]
750    fn an_omitted_landing_key_is_distinguishable_from_an_explicit_default() {
751        let omitted = parse("schema_version = 1\n").expect("omitted answers parse");
752        let explicit = parse(
753            "schema_version = 1\n[landing]\nworkflow = 'worktree'\nstyle = 'trunk'\nnix = false\n",
754        )
755        .expect("explicit defaults parse");
756        assert_eq!(omitted.landing, super::Landing::default());
757        assert_eq!(explicit.landing.workflow, Some(Workflow::Worktree));
758        assert_eq!(explicit.landing.style, Some(Style::Trunk));
759        assert_eq!(explicit.landing.nix, Some(false));
760        assert_ne!(omitted, explicit);
761    }
762
763    #[test]
764    fn the_landed_config_template_round_trips() {
765        let dir = tempfile::tempdir().expect("a target exists");
766        let mut config = Config::default();
767        config.project.repo = "acme/nested/widget".into();
768        config.project.forge = "gitlab".into();
769        config.project.tech = "bash".into();
770        config.project.trunk = Some("main".into());
771        config.landing.workflow = Some(Workflow::Branches);
772        config.landing.style = Some(Style::Lines);
773        config.landing.nix = Some(true);
774        config.security.advisories = "acme/private".into();
775        config.security.contact = "A \"quoted\" contact\nRK_CONFIG_SECURITY_RESPONSE\\end".into();
776        config.security.response = "90d".into();
777        config.setup.required_check = "build / test".into();
778        config.setup.retired_branches = vec!["develop".into(), "old\"branch".into()];
779        config.setup.line_prefix = Some("stable/".into());
780        config.setup.release_lines = true;
781        config.setup.bot.app_id = "123".into();
782        config.setup.bot.installation_id = 456;
783        config.protection.trunk_ruleset = "primary".into();
784        config.protection.tag_ruleset = "versions".into();
785        config.protection.lines_ruleset = "maintenance".into();
786        config.protection.title_check = "intent".into();
787        config.protection.tag_pattern = "refs/tags/*".into();
788        config
789            .protection
790            .owned_trunk_rules
791            .push("required_signatures".into());
792        config.protection.required_approving_review_count = 2;
793        config.protection.dismiss_stale_reviews_on_push = true;
794        config.protection.require_code_owner_review = true;
795        config.protection.require_last_push_approval = true;
796        config.protection.gitlab.squash_commit_template =
797            "%{title}\n\nContext: %{description}".into();
798        config.protection.gitlab.merge_access_level = 40;
799        let defaults = Config {
800            landing: super::Landing {
801                workflow: Some(Workflow::Worktree),
802                style: Some(Style::Trunk),
803                nix: Some(false),
804            },
805            project: super::Project {
806                trunk: Some(super::TRUNK_DEFAULT.into()),
807                ..super::Project::default()
808            },
809            setup: super::Setup {
810                line_prefix: Some(super::LINE_PREFIX_DEFAULT.into()),
811                ..super::Setup::default()
812            },
813            ..Config::default()
814        };
815        for expected in [defaults, config] {
816            write(dir.path(), &expected).expect("the template renders");
817            assert_eq!(load(dir.path()).expect("the config reads"), Some(expected));
818            let text =
819                std::fs::read_to_string(dir.path().join(CONFIG_PATH)).expect("the text reads");
820            assert!(text.contains("# P: project path"));
821            assert!(text.contains("# F: invariant"));
822        }
823    }
824
825    #[test]
826    fn a_config_with_an_unknown_key_refuses_by_name() {
827        for (table, typo, nearest) in [
828            ("", "schemax_version", "schema_version"),
829            ("project", "trunkx", "trunk"),
830            ("landing", "stile", "style"),
831            ("security", "contactx", "contact"),
832            ("setup", "required_checkx", "required_check"),
833            ("setup.bot", "app_i", "app_id"),
834            ("protection", "trunk_rulesett", "trunk_ruleset"),
835            (
836                "protection.github",
837                "squash_body_sourcex",
838                "squash_body_source",
839            ),
840            ("protection.gitlab", "squash_optionx", "squash_option"),
841        ] {
842            let header = if table.is_empty() {
843                String::new()
844            } else {
845                format!("[{table}]\n")
846            };
847            let text = format!("schema_version = 1\n{header}{typo} = 'value'\n");
848            let error = parse(&text).expect_err("unknown keys refuse").to_string();
849            for expected in [CONFIG_PATH, typo, &format!("nearest known key: {nearest}")] {
850                assert!(error.contains(expected), "{error}");
851            }
852        }
853    }
854
855    #[test]
856    fn a_config_at_an_unknown_schema_refuses() {
857        for text in ["schema_version = 999", "schema_version = '1'", ""] {
858            let error = parse(text)
859                .expect_err("a schema must be declared and known")
860                .to_string();
861            assert!(
862                error.contains(CONFIG_PATH) && error.contains("schema_version"),
863                "{error}"
864            );
865        }
866    }
867
868    #[test]
869    fn an_unparsable_config_refuses_naming_the_position() {
870        let error = parse("schema_version = 1\n[project\n")
871            .expect_err("bad TOML refuses")
872            .to_string();
873        for expected in [CONFIG_PATH, "line 2", "column"] {
874            assert!(error.contains(expected), "{error}");
875        }
876    }
877
878    #[test]
879    fn an_absent_config_reads_as_none() {
880        let dir = tempfile::tempdir().expect("a target exists");
881        assert_eq!(load(dir.path()).expect("absence is compatible"), None);
882        assert_eq!(trunk_of(dir.path()).expect("the default reads"), "master");
883    }
884
885    #[test]
886    fn loading_checks_floors_and_trunk_of_propagates_invalid_content() {
887        let dir = tempfile::tempdir().expect("a target exists");
888        std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
889        std::fs::write(
890            dir.path().join(CONFIG_PATH),
891            "schema_version = 1\n[protection]\nstrict_required_status_checks = false\n",
892        )
893        .expect("a config exists");
894        let error =
895            trunk_of(dir.path()).expect_err("invalid policy refuses even through the accessor");
896        assert_eq!(error.exit_code(), 73);
897        assert!(
898            error
899                .to_string()
900                .contains("protection.strict_required_status_checks")
901        );
902    }
903
904    #[test]
905    fn rewrite_key_preserves_comments() {
906        let dir = tempfile::tempdir().expect("a target exists");
907        std::fs::create_dir(dir.path().join(".release-kit")).expect("the directory exists");
908        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";
909        let path = dir.path().join(CONFIG_PATH);
910        std::fs::write(&path, original).expect("a config exists");
911        rewrite_key(dir.path(), "landing.style", "lines".into()).expect("the style writes back");
912        let text = std::fs::read_to_string(&path).expect("the text reads");
913        assert_eq!(text, original.replace("'trunk'", "\"lines\""));
914        assert_eq!(
915            load(dir.path())
916                .expect("the config reads")
917                .expect("present")
918                .landing
919                .style,
920            Some(Style::Lines)
921        );
922        rewrite_key(dir.path(), "project.repo", "acme/widget".into())
923            .expect("an omitted table can be added");
924        assert_eq!(
925            load(dir.path())
926                .expect("reads")
927                .expect("present")
928                .project
929                .repo,
930            "acme/widget"
931        );
932        let before = std::fs::read(&path).expect("the bytes read");
933        for (key, value) in [("security.contact", "other"), ("landing.style", "unknown")] {
934            assert!(rewrite_key(dir.path(), key, value.into()).is_err());
935            assert_eq!(std::fs::read(&path).expect("the bytes read"), before);
936        }
937    }
938}