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