Skip to main content

release_kit/
profile.rs

1//! The resolved target configuration.
2//!
3//! The project profile, the Git workflow, and the capability requests,
4//! resolved by one precedence from an invocation's flags, the committed
5//! configuration, a compatible record, the target's observation, and the
6//! compiled defaults.
7//!
8//! Three representations live here and stay apart. The declared
9//! configuration is `crate::config::Config`, exactly as authored. The
10//! resolved configuration is [`Resolved`]: every effective value beside the
11//! runtime [`Source`] that answered it, which `rk profile` reports and
12//! nothing serializes. The wire form is [`Params`]: the same values with no
13//! source, which the projection consumes, the configuration writes back,
14//! and the record carries as [`ProfileSnapshot`], [`GitWorkflow`], and
15//! [`CapabilityRequests`].
16//!
17//! SATISFIES project-profile:every-field-resolves-by-one-precedence
18//! SATISFIES project-profile:a-record-is-source-free
19
20pub mod catalog;
21
22use std::collections::BTreeMap;
23
24use camino::Utf8Path;
25use serde::{Deserialize, Serialize};
26
27use crate::config::dev_environment::DevEnvironment;
28use crate::diagnostic::{Diagnostic, Reason};
29use crate::error::RkError;
30use crate::landing::manifest::{self, CheckoutMode, Integration, MergeForm, Provider, Style};
31
32/// The release intent's mode.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ReleaseMode {
36    /// release-kit drives the release: a bot maintains the request, and
37    /// the landed automation tags and publishes.
38    Automatic,
39    /// The target releases through a process release-kit does not drive.
40    /// No automation lands, and no bot-operate chapter applies.
41    External,
42    /// Nothing releases.
43    None,
44}
45
46impl ReleaseMode {
47    /// The flag, wire, and report form.
48    #[must_use]
49    pub const fn as_str(self) -> &'static str {
50        match self {
51            Self::Automatic => "automatic",
52            Self::External => "external",
53            Self::None => "none",
54        }
55    }
56
57    /// Parse a `--release-mode` flag value.
58    ///
59    /// # Errors
60    ///
61    /// Returns [`RkError::Usage`] naming the three values.
62    pub fn parse(raw: &str) -> Result<Self, RkError> {
63        match raw {
64            "automatic" => Ok(Self::Automatic),
65            "external" => Ok(Self::External),
66            "none" => Ok(Self::None),
67            other => Err(RkError::Usage(format!(
68                "unknown release mode '{other}'; the modes are: automatic, external, none"
69            ))),
70        }
71    }
72}
73
74/// The release intent: the mode and, for an automatic release, its driver,
75/// style, and line prefix.
76///
77/// SATISFIES project-profile:release-intent-has-three-modes
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct ReleaseIntent {
80    /// The mode.
81    pub mode: ReleaseMode,
82    /// The technology that states the version and takes the bot; present
83    /// for an automatic release alone.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub driver: Option<String>,
86    /// The release style; present for an automatic release alone.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub style: Option<Style>,
89    /// The release-line branch prefix; present for an automatic release
90    /// alone.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub line_prefix: Option<String>,
93}
94
95/// What the project is, on the wire: values alone.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct ProfileSnapshot {
98    /// The technologies present, sorted, zero or many.
99    pub technologies: Vec<String>,
100    /// The forge, where the project has one.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub forge: Option<String>,
103    /// The release intent.
104    pub release: ReleaseIntent,
105}
106
107/// The Git workflow parameters.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct GitWorkflow {
110    /// The one permanent branch.
111    pub trunk: String,
112    /// Where a topic branch opens.
113    pub checkout_mode: CheckoutMode,
114    /// Which authority moves an implementation onto the trunk. A record
115    /// predating the parameter carries no key and reads as `forge`.
116    #[serde(default = "crate::landing::manifest::integration_forge")]
117    pub integration: Integration,
118    /// How the commits of a branch reach the trunk. A record predating
119    /// the parameter carries no key and reads as `squash`.
120    #[serde(default = "crate::landing::manifest::merge_form_squash")]
121    pub merge_form: MergeForm,
122}
123
124/// The optional products the target requested.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct CapabilityRequests {
127    /// The seeded package expression and the seed flake pair.
128    #[serde(default)]
129    pub nix_packaging: bool,
130    /// The landed vulnerability reporting policy.
131    #[serde(default)]
132    pub reporting_policy: bool,
133    /// The `OpenSSF` Scorecard workflow.
134    #[serde(default)]
135    pub scorecard: bool,
136    /// The code scanning workflow, by provider.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub code_scanning: Option<Provider>,
139    /// The workflow guards: the agent and commit policy lines, the
140    /// glossary, the workflow hooks, and two setup steps. A record that
141    /// predates the capability carries no key: that is the pending answer,
142    /// and it reads as kept until the operator answers it.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub workflow_guards: Option<bool>,
145}
146
147impl CapabilityRequests {
148    /// Whether the workflow guards land. The pending answer of a record
149    /// that predates the capability reads as kept, because every such
150    /// target carries them.
151    #[must_use]
152    pub fn workflow_guards(&self) -> bool {
153        self.workflow_guards.unwrap_or(true)
154    }
155}
156
157/// Where a resolved value came from.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
159#[serde(rename_all = "lowercase")]
160pub enum Source {
161    /// An invocation flag.
162    Flag,
163    /// The committed configuration.
164    Config,
165    /// A compatible landing record.
166    Record,
167    /// The target's observation: its version files and its origin remote.
168    Observation,
169    /// A compiled default.
170    Default,
171}
172
173impl Source {
174    /// Where this source sits in the one precedence, lowest first.
175    ///
176    /// The comparison a resolution needs when one field's answer has to be
177    /// weighed against another's: a value only contradicts a decision that
178    /// its own tier or a lower one made.
179    #[must_use]
180    pub const fn rank(self) -> u8 {
181        match self {
182            Self::Flag => 0,
183            Self::Config => 1,
184            Self::Record => 2,
185            Self::Observation => 3,
186            Self::Default => 4,
187        }
188    }
189
190    /// The report form.
191    #[must_use]
192    pub const fn as_str(self) -> &'static str {
193        match self {
194            Self::Flag => "flag",
195            Self::Config => "config",
196            Self::Record => "record",
197            Self::Observation => "observation",
198            Self::Default => "default",
199        }
200    }
201}
202
203/// What the observation proposes for the release, where nothing else
204/// answered it.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
206#[serde(rename_all = "kebab-case", tag = "state")]
207pub enum Proposal {
208    /// No release-bearing technology: nothing to automate.
209    None,
210    /// Exactly one release-bearing technology drives the release.
211    Automatic {
212        /// The driver.
213        driver: String,
214    },
215    /// More than one release-bearing technology, so a flag must name the
216    /// driver before an apply.
217    Ambiguous {
218        /// The candidates, sorted.
219        drivers: Vec<String>,
220    },
221}
222
223/// The complete resolved input to a projection, on the wire.
224///
225/// The values the configuration writes back, the record carries, and the
226/// projection renders from, with no precedence source.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct Params {
229    profile: ProfileSnapshot,
230    git: GitWorkflow,
231    capabilities: CapabilityRequests,
232    repo: String,
233    security_contact: String,
234    security_response: String,
235    required_check: String,
236    required_workflow: String,
237    dev_environment: Option<DevEnvironment>,
238}
239
240/// Explicit invocation answers; absence falls through to configuration.
241#[derive(Default)]
242pub struct Inputs<'a> {
243    /// The technologies, replacing the declared list whole; empty is
244    /// unsupplied.
245    pub technologies: &'a [String],
246    /// Forge override.
247    pub forge: Option<&'a str>,
248    /// Repository override.
249    pub repo: Option<&'a str>,
250    /// Release mode override.
251    pub release_mode: Option<ReleaseMode>,
252    /// Release driver override.
253    pub release_driver: Option<&'a str>,
254    /// Release style override.
255    pub style: Option<Style>,
256    /// Trunk override.
257    pub trunk: Option<&'a str>,
258    /// Checkout mode override.
259    pub checkout_mode: Option<CheckoutMode>,
260    /// Integration mode override.
261    pub integration: Option<Integration>,
262    /// Merge form override.
263    pub merge_form: Option<MergeForm>,
264    /// The check the release gate believes, overriding the configuration.
265    pub required_check: Option<&'a str>,
266    /// The workflow whose completion wakes the release gate, overriding
267    /// the configuration.
268    pub required_workflow: Option<&'a str>,
269    /// Nix packaging request override.
270    pub nix: Option<bool>,
271    /// Reporting policy request override.
272    pub reporting_policy: Option<bool>,
273    /// Scorecard request override.
274    pub scorecard: Option<bool>,
275    /// Code scanning override: `Some(None)` turns it off, and absence
276    /// leaves the configuration and the record to answer.
277    pub code_scanning: Option<Option<Provider>>,
278    /// Workflow guards request override.
279    pub workflow_guards: Option<bool>,
280}
281
282/// Compatibility policy for a landing candidate.
283#[derive(Clone, Copy, PartialEq, Eq)]
284pub enum Purpose {
285    /// A first landing.
286    Init,
287    /// A preview may leave the repository unresolved and reports an
288    /// ambiguous release proposal rather than refusing it.
289    Preview,
290    /// An existing record supplies compatibility answers.
291    Upgrade,
292    /// A pre-record target requires an explicit release style.
293    Adopt,
294}
295
296/// The resolved target configuration, with the source of every value.
297#[derive(Debug, Clone)]
298pub struct Resolved {
299    /// The values.
300    pub params: Params,
301    /// The source of each value, keyed by its configuration path.
302    pub sources: BTreeMap<&'static str, Source>,
303    /// The category names the catalog does not know, preserved.
304    pub unknown: Vec<String>,
305    /// What the observation proposed for the release, where the mode was
306    /// not answered above it.
307    pub proposal: Option<Proposal>,
308}
309
310/// The canonical form of a category name, or why it is refused.
311///
312/// Lowercase, matching `[a-z0-9][a-z0-9-]*`. An unknown name is preserved, so the
313/// shape is what keeps a record and a configuration readable.
314///
315/// SATISFIES project-profile:an-unknown-category-is-preserved
316///
317/// # Errors
318/// The refusal text, naming the value and the shape.
319pub fn canonical_category(raw: &str) -> Result<String, String> {
320    let lowered = raw.trim().to_ascii_lowercase();
321    let shaped = lowered
322        .chars()
323        .next()
324        .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
325        && lowered
326            .chars()
327            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
328    if shaped {
329        Ok(lowered)
330    } else {
331        Err(format!(
332            "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
333        ))
334    }
335}
336
337/// A list of category names canonicalized, refused on a duplicate, and
338/// sorted.
339///
340/// # Errors
341/// The refusal text, naming the key.
342pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
343    let mut out = Vec::with_capacity(raw.len());
344    for value in raw {
345        let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
346        if out.contains(&name) {
347            return Err(format!("{key} names {name} twice; each technology once"));
348        }
349        out.push(name);
350    }
351    out.sort();
352    Ok(out)
353}
354
355/// Append one POSIX-shell word, leaving the common inert alphabet readable
356/// and single-quoting everything that could become syntax on replay.
357fn push_shell_word(out: &mut String, value: &str) {
358    let inert = !value.is_empty()
359        && value.bytes().all(|byte| {
360            byte.is_ascii_alphanumeric()
361                || matches!(
362                    byte,
363                    b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
364                )
365        });
366    if inert {
367        out.push_str(value);
368        return;
369    }
370    out.push('\'');
371    out.push_str(&value.replace('\'', "'\"'\"'"));
372    out.push('\'');
373}
374
375impl Params {
376    /// Reconstruct every projection parameter from the record alone,
377    /// including the compatibility defaults applied when it was loaded.
378    #[must_use]
379    pub fn from_record(record: &manifest::Manifest) -> Self {
380        Self {
381            profile: record.profile.clone(),
382            git: record.git.clone(),
383            capabilities: record.capabilities.clone(),
384            repo: record.parameters.repo.clone(),
385            security_contact: record.parameters.security_contact.clone(),
386            security_response: record.parameters.security_response.clone(),
387            // A record predating the two answers carries neither, and
388            // the compiled default is what such a landing renders now.
389            required_check: gate_answer(
390                record,
391                |(check, _)| check,
392                |parameters| &parameters.required_check,
393            ),
394            required_workflow: gate_answer(
395                record,
396                |(_, workflow)| workflow,
397                |parameters| &parameters.required_workflow,
398            ),
399            dev_environment: record.parameters.dev_environment.clone(),
400        }
401    }
402
403    /// Resolve flags, configuration, recorded compatibility inputs or
404    /// observation, and finally the compiled defaults. Comparisons use
405    /// `from_record` alone.
406    ///
407    /// # Errors
408    /// Refuses unresolved identity, an invalid release state, or a style
409    /// an existing target has not answered.
410    pub fn resolve(
411        target: &Utf8Path,
412        flags: &Inputs<'_>,
413        config: Option<&crate::config::Config>,
414        record: Option<&manifest::Manifest>,
415        purpose: Purpose,
416    ) -> Result<Self, RkError> {
417        resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
418    }
419
420    /// What the project is.
421    #[must_use]
422    pub const fn profile(&self) -> &ProfileSnapshot {
423        &self.profile
424    }
425
426    /// The Git workflow parameters.
427    #[must_use]
428    pub const fn git(&self) -> &GitWorkflow {
429        &self.git
430    }
431
432    /// The capability requests.
433    #[must_use]
434    pub const fn capabilities(&self) -> &CapabilityRequests {
435        &self.capabilities
436    }
437
438    /// The technologies present, sorted.
439    #[must_use]
440    pub fn technologies(&self) -> &[String] {
441        &self.profile.technologies
442    }
443
444    /// The forge, where the project has one.
445    #[must_use]
446    pub fn forge(&self) -> Option<&str> {
447        self.profile.forge.as_deref()
448    }
449
450    /// The release mode.
451    #[must_use]
452    pub const fn release_mode(&self) -> ReleaseMode {
453        self.profile.release.mode
454    }
455
456    /// The release driver, for an automatic release.
457    #[must_use]
458    pub fn driver(&self) -> Option<&str> {
459        self.profile.release.driver.as_deref()
460    }
461
462    /// The release style, for an automatic release that answered it.
463    #[must_use]
464    pub const fn style(&self) -> Option<Style> {
465        self.profile.release.style
466    }
467
468    /// Whether this landing requested the Nix capability.
469    #[must_use]
470    pub const fn nix_packaging(&self) -> bool {
471        self.capabilities.nix_packaging
472    }
473
474    /// Whether this landing requested the reporting policy.
475    #[must_use]
476    pub const fn reporting_policy(&self) -> bool {
477        self.capabilities.reporting_policy
478    }
479
480    /// Whether this landing requested the Scorecard capability.
481    #[must_use]
482    pub const fn scorecard(&self) -> bool {
483        self.capabilities.scorecard
484    }
485
486    /// The code scanning provider this landing requested, if any.
487    #[must_use]
488    pub const fn code_scanning(&self) -> Option<Provider> {
489        self.capabilities.code_scanning
490    }
491
492    /// Whether the workflow guards land for this landing.
493    #[must_use]
494    pub fn workflow_guards(&self) -> bool {
495        self.capabilities.workflow_guards()
496    }
497
498    /// The project path used by parameter-bearing files, empty where the
499    /// target has no forge repository.
500    #[must_use]
501    pub fn repo(&self) -> &str {
502        &self.repo
503    }
504
505    /// Where a topic branch opens.
506    #[must_use]
507    pub const fn checkout_mode(&self) -> CheckoutMode {
508        self.git.checkout_mode
509    }
510
511    /// Which authority moves an implementation onto the trunk.
512    #[must_use]
513    pub const fn integration(&self) -> Integration {
514        self.git.integration
515    }
516
517    /// How the commits of a branch reach the trunk.
518    #[must_use]
519    pub const fn merge_form(&self) -> MergeForm {
520        self.git.merge_form
521    }
522
523    /// The one permanent branch this landing writes into its artifacts.
524    #[must_use]
525    pub fn trunk(&self) -> &str {
526        &self.git.trunk
527    }
528
529    /// The release-line prefix this landing writes into its artifacts,
530    /// the compiled default where the release intent carries none.
531    #[must_use]
532    pub fn line_prefix(&self) -> &str {
533        self.profile
534            .release
535            .line_prefix
536            .as_deref()
537            .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
538    }
539
540    /// The contact the landed policy names, empty for the forge's own
541    /// authored wording.
542    #[must_use]
543    pub fn security_contact(&self) -> &str {
544        &self.security_contact
545    }
546
547    /// The acknowledgment window the landed policy promises.
548    #[must_use]
549    pub fn security_response(&self) -> &str {
550        &self.security_response
551    }
552
553    /// The check the release gate believes, empty where this target
554    /// renders no gate that reads one.
555    #[must_use]
556    pub fn required_check(&self) -> &str {
557        &self.required_check
558    }
559
560    /// How a command runs in the project's own development environment,
561    /// `None` where the project gives no answer.
562    #[must_use]
563    pub const fn dev_environment(&self) -> Option<&DevEnvironment> {
564        self.dev_environment.as_ref()
565    }
566
567    /// The workflow whose completion wakes the release gate, empty where
568    /// this target renders no gate that waits on one.
569    #[must_use]
570    pub fn required_workflow(&self) -> &str {
571        &self.required_workflow
572    }
573
574    /// The canonical identity and Git workflow flags, as `rk init` and
575    /// `rk adopt` take them: every resolved answer stated, so a follow-up
576    /// command a preview prints applies the decision that was previewed.
577    #[must_use]
578    pub fn canonical_flags(&self) -> String {
579        let mut out = String::new();
580        for technology in &self.profile.technologies {
581            out.push_str(" --technology ");
582            out.push_str(technology);
583        }
584        if let Some(forge) = &self.profile.forge {
585            out.push_str(" --forge ");
586            out.push_str(forge);
587        }
588        // A preview stands in for an unresolved repository with the
589        // placeholder, and a replayed apply takes the operator's own path
590        // rather than that stand-in, so the flag stays out of the command.
591        if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
592            out.push_str(" --repo ");
593            out.push_str(&self.repo);
594        }
595        out.push_str(" --release-mode ");
596        out.push_str(self.profile.release.mode.as_str());
597        if let Some(driver) = &self.profile.release.driver {
598            out.push_str(" --release-driver ");
599            out.push_str(driver);
600        }
601        if let Some(style) = self.profile.release.style {
602            out.push_str(" --release-style ");
603            out.push_str(style.as_str());
604        }
605        out.push_str(" --trunk ");
606        out.push_str(&self.git.trunk);
607        out.push_str(" --checkout-mode ");
608        out.push_str(self.git.checkout_mode.as_str());
609        out.push_str(" --integration ");
610        out.push_str(self.git.integration.as_str());
611        out.push_str(" --merge-form ");
612        out.push_str(self.git.merge_form.as_str());
613        // An unanswered gate has no flag form: the empty answer is the
614        // compiled default, and a replayed command that stated it would
615        // pass a name the resolution never produced.
616        if !self.required_check.is_empty() {
617            out.push_str(" --required-check ");
618            push_shell_word(&mut out, &self.required_check);
619        }
620        if !self.required_workflow.is_empty() {
621            out.push_str(" --required-workflow ");
622            push_shell_word(&mut out, &self.required_workflow);
623        }
624        out
625    }
626
627    /// Every opt-in capability's flag, as `rk init` and `rk adopt` take it.
628    ///
629    /// The resolved answers, not the flags the caller typed: a follow-up
630    /// command a preview prints must apply the decision that was previewed,
631    /// and the preview's decision is what resolution produced.
632    #[must_use]
633    pub fn capability_flags(&self) -> String {
634        let mut out = String::new();
635        if self.capabilities.nix_packaging {
636            out.push_str(" --nix-packaging");
637        }
638        if self.capabilities.reporting_policy {
639            out.push_str(" --reporting-policy");
640        }
641        if self.capabilities.scorecard {
642            out.push_str(" --scorecard");
643        }
644        if self.workflow_guards() {
645            out.push_str(" --workflow-guards");
646        }
647        // The provider flag takes a value, so `off` is a statable answer and
648        // is stated: a committed `capabilities.code_scanning` would
649        // otherwise re-enable on replay exactly what this preview turned
650        // off. The boolean flags above have no off form, so absence is
651        // their only honest rendering and no committed value can
652        // contradict it.
653        out.push_str(" --code-scanning ");
654        out.push_str(
655            self.capabilities
656                .code_scanning
657                .map_or("off", Provider::as_str),
658        );
659        out
660    }
661
662    /// The same answers as `rk upgrade` takes them, every one stated.
663    ///
664    /// An upgrade can turn a capability off as well as on, so absence is no
665    /// answer there and each value is rendered explicitly. That is what makes
666    /// a printed follow-up command reproduce the previewed decision rather
667    /// than re-resolve the configured one.
668    #[must_use]
669    pub fn capability_toggles(&self) -> String {
670        let word = |on: bool| if on { "on" } else { "off" };
671        format!(
672            " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {} --workflow-guards {}",
673            word(self.capabilities.nix_packaging),
674            word(self.capabilities.reporting_policy),
675            word(self.capabilities.scorecard),
676            self.capabilities
677                .code_scanning
678                .map_or("off", Provider::as_str),
679            word(self.workflow_guards())
680        )
681    }
682}
683
684#[cfg(test)]
685impl Params {
686    /// A parameter set for tests alone: an automatic rust release on
687    /// GitHub. Production code reaches `Params` through `from_record` and
688    /// `resolve` and through nothing else, and this constructor is
689    /// compiled out of the shipped binary.
690    pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
691        Self {
692            profile: ProfileSnapshot {
693                technologies: vec!["rust".to_owned()],
694                forge: Some("github".to_owned()),
695                release: ReleaseIntent {
696                    mode: ReleaseMode::Automatic,
697                    driver: Some("rust".to_owned()),
698                    style,
699                    line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
700                },
701            },
702            git: GitWorkflow {
703                trunk: crate::config::TRUNK_DEFAULT.to_owned(),
704                checkout_mode: CheckoutMode::LinkedWorktree,
705                integration: Integration::Local,
706                merge_form: MergeForm::Squash,
707            },
708            capabilities: CapabilityRequests {
709                nix_packaging: false,
710                reporting_policy: true,
711                scorecard: false,
712                code_scanning: None,
713                workflow_guards: Some(true),
714            },
715            repo: repo.to_owned(),
716            security_contact: String::new(),
717            security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
718            required_check: "gate".to_owned(),
719            required_workflow: "ci".to_owned(),
720            dev_environment: None,
721        }
722    }
723
724    /// The same set with the two security parameters answered.
725    pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
726        Self {
727            security_contact: contact.to_owned(),
728            security_response: response.to_owned(),
729            ..Self::for_test("acme/widget", Some(Style::Trunk))
730        }
731    }
732
733    /// A release-less set for tests: the technologies and the forge as
734    /// given, no driver, no style.
735    pub(crate) fn for_test_release_less(
736        technologies: &[&str],
737        forge: Option<&str>,
738        mode: ReleaseMode,
739    ) -> Self {
740        let mut params = Self::for_test("acme/widget", None);
741        params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
742        params.profile.forge = forge.map(str::to_owned);
743        params.profile.release = ReleaseIntent {
744            mode,
745            driver: None,
746            style: None,
747            line_prefix: None,
748        };
749        params.capabilities.reporting_policy = false;
750        if forge.is_none() {
751            params.repo = String::new();
752        }
753        params
754    }
755
756    /// The same set with the forge and the driver changed.
757    pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
758        self.profile.technologies = vec![driver.to_owned()];
759        self.profile.release.driver = Some(driver.to_owned());
760        self.profile.forge = Some(forge.to_owned());
761    }
762
763    /// The same set with the checkout mode answered.
764    pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
765        self.git.checkout_mode = mode;
766    }
767
768    /// The same set with the integration mode answered.
769    pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
770        self.git.integration = mode;
771    }
772
773    /// The same set with the merge form answered.
774    pub(crate) const fn set_merge_form_for_test(&mut self, form: MergeForm) {
775        self.git.merge_form = form;
776    }
777
778    /// The same set with the Nix opt-in answered.
779    pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
780        self.capabilities.nix_packaging = nix;
781    }
782
783    /// The same set with the Scorecard opt-in answered.
784    pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
785        self.capabilities.scorecard = scorecard;
786    }
787
788    /// The same set with the code scanning provider answered.
789    pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
790        self.capabilities.code_scanning = provider;
791    }
792
793    /// The same set with the workflow guards answered.
794    pub(crate) const fn set_workflow_guards_for_test(&mut self, on: bool) {
795        self.capabilities.workflow_guards = Some(on);
796    }
797}
798
799/// The refusal for an invalid release state, naming the key and its
800/// valid shape.
801fn invalid_release(message: impl std::fmt::Display) -> RkError {
802    RkError::Usage(format!(
803        "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
804    ))
805}
806
807/// The canonical release gate answers, for the one shape that renders a
808/// gate reading them, or `None` for every other shape.
809///
810/// One shape consumes them: GitHub, an automatic release, the trunk
811/// style, and local integration. There the rendered release workflow
812/// wakes on a workflow completing and judges a named check, so a landing
813/// that left either empty would render a gate no event can ever satisfy.
814/// The names are this convention's own: `runbooks/setup.md` writes the
815/// gate job as `gate`, and `ci` is the workflow that carries it.
816///
817/// They are a compiled default and nothing more. A flag, the committed
818/// configuration, and a compatible record each answer ahead of them, the
819/// resolved answer is written back and recorded, and `rk setup check`
820/// proves it against the target's own workflow files. A project whose
821/// names differ states them and the default never applies.
822///
823/// Every other shape resolves to empty, because no rendered reader
824/// consumes the answer there. Under forge integration the trunk ruleset
825/// holds the release request and `setup.required_check` names the context
826/// that ruleset requires, which is the project's own answer and not this
827/// convention's, so `protect-trunk` keeps asking for it.
828#[must_use]
829pub fn gate_defaults(
830    profile: &ProfileSnapshot,
831    git: &GitWorkflow,
832) -> Option<(&'static str, &'static str)> {
833    let consuming = profile.forge.as_deref() == Some("github")
834        && profile.release.mode == ReleaseMode::Automatic
835        && profile.release.style == Some(Style::Trunk)
836        && git.integration == Integration::Local;
837    consuming.then_some(("gate", "ci"))
838}
839
840/// One gate answer read back from a record, falling to the compiled
841/// default where the record predates the field.
842fn gate_answer(
843    record: &manifest::Manifest,
844    pick: impl Fn((&'static str, &'static str)) -> &'static str,
845    held: impl Fn(&manifest::Parameters) -> &String,
846) -> String {
847    let recorded = held(&record.parameters);
848    if recorded.is_empty() {
849        gate_defaults(&record.profile, &record.git)
850            .map_or_else(String::new, |pair| pick(pair).to_owned())
851    } else {
852        recorded.clone()
853    }
854}
855
856/// One field's answer and where it came from.
857fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
858    chain
859        .into_iter()
860        .find_map(|(value, source)| value.map(|value| (value, source)))
861}
862
863/// Resolve every domain value by one precedence, keeping the source of
864/// each.
865///
866/// # Errors
867/// Refuses unresolved identity, an invalid release state, a duplicate or
868/// malformed category name, and a style an existing target has not
869/// answered.
870#[allow(
871    clippy::too_many_lines,
872    reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
873)]
874pub fn resolve(
875    target: &Utf8Path,
876    flags: &Inputs<'_>,
877    config: Option<&crate::config::Config>,
878    record: Option<&manifest::Manifest>,
879    purpose: Purpose,
880) -> Result<Resolved, RkError> {
881    let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
882    let observed = crate::detect::observe(target.as_std_path());
883    let known_drivers = catalog::known_drivers();
884
885    // Technologies: a supplied list replaces the declared one whole.
886    let (technologies, source) = answered([
887        (
888            (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
889            Source::Flag,
890        ),
891        (
892            config.and_then(|c| c.profile.technologies.clone()),
893            Source::Config,
894        ),
895        (
896            record.map(|r| r.profile.technologies.clone()),
897            Source::Record,
898        ),
899        (
900            Some(
901                observed
902                    .technologies
903                    .iter()
904                    .map(|t| (*t).to_owned())
905                    .collect(),
906            ),
907            Source::Observation,
908        ),
909        (None, Source::Default),
910    ])
911    .unwrap_or_else(|| (Vec::new(), Source::Default));
912    let technologies =
913        canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
914    sources.insert("profile.technologies", source);
915
916    // The forge: an explicit empty configuration value states no forge.
917    let forge_flag = flags
918        .forge
919        .map(|name| canonical_category(name).map_err(RkError::Usage))
920        .transpose()?;
921    let (forge, source) = answered([
922        (forge_flag.map(Some), Source::Flag),
923        (
924            config
925                .and_then(|c| c.profile.forge.clone())
926                .map(|value| if value.is_empty() { None } else { Some(value) }),
927            Source::Config,
928        ),
929        (record.map(|r| r.profile.forge.clone()), Source::Record),
930        (
931            observed.forge.map(|forge| Some(forge.as_str().to_owned())),
932            Source::Observation,
933        ),
934        (Some(None), Source::Default),
935    ])
936    .unwrap_or((None, Source::Default));
937    let forge = forge
938        .map(|name| canonical_category(&name).map_err(RkError::Usage))
939        .transpose()?;
940    sources.insert("profile.forge", source);
941
942    // The repository identity, needed only where a forge is present.
943    let (repo, source) = answered([
944        (flags.repo.map(str::to_owned), Source::Flag),
945        (
946            config
947                .map(|c| c.project.repo.clone())
948                .filter(|value| !value.is_empty()),
949            Source::Config,
950        ),
951        (
952            record
953                .map(|r| r.parameters.repo.clone())
954                .filter(|value| !value.is_empty()),
955            Source::Record,
956        ),
957        (observed.repo.clone(), Source::Observation),
958        (None, Source::Default),
959    ])
960    .map_or((None, Source::Default), |(value, source)| {
961        (Some(value), source)
962    });
963    sources.insert("project.repo", source);
964
965    // The release mode: the observation proposes where nothing above
966    // answers.
967    let release_bearing: Vec<String> = technologies
968        .iter()
969        .filter(|name| known_drivers.contains(name))
970        .cloned()
971        .collect();
972    let proposal = match release_bearing.as_slice() {
973        [] => Proposal::None,
974        [one] => Proposal::Automatic {
975            driver: one.clone(),
976        },
977        many => Proposal::Ambiguous {
978            drivers: many.to_vec(),
979        },
980    };
981    // The proposal reads the version files alone. A missing forge is not
982    // an answer about the release intent: it is a separate refusal the
983    // automatic branch below raises, naming the remote it did not find and
984    // the two ways out. Folding it in here would silently land a
985    // release-less target for a crate whose author simply has no remote
986    // yet, and the record would then claim a release intent nobody stated.
987    let proposed_mode = match &proposal {
988        Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
989        Proposal::None => ReleaseMode::None,
990    };
991    let (mode, source) = answered([
992        (flags.release_mode, Source::Flag),
993        (config.and_then(|c| c.profile.release.mode), Source::Config),
994        (record.map(|r| r.profile.release.mode), Source::Record),
995        (Some(proposed_mode), Source::Observation),
996        (None, Source::Default),
997    ])
998    .unwrap_or((ReleaseMode::None, Source::Default));
999    let mode_source = source;
1000    sources.insert("profile.release.mode", mode_source);
1001    let mode_answered_above = mode_source != Source::Observation;
1002    let proposal = (!mode_answered_above).then_some(proposal);
1003
1004    // The driver, style, and line prefix belong to an automatic release
1005    // alone, and a value from a flag or the configuration under another
1006    // mode is a malformed intent rather than an ignored one.
1007    let driver_flag = flags
1008        .release_driver
1009        .map(|name| canonical_category(name).map_err(RkError::Usage))
1010        .transpose()?;
1011    let (driver, driver_source) = answered([
1012        (driver_flag, Source::Flag),
1013        (
1014            config.and_then(|c| c.profile.release.driver.clone()),
1015            Source::Config,
1016        ),
1017        (
1018            record.and_then(|r| r.profile.release.driver.clone()),
1019            Source::Record,
1020        ),
1021        (
1022            match &proposal {
1023                Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
1024                    Some(driver.clone())
1025                }
1026                _ => None,
1027            },
1028            Source::Observation,
1029        ),
1030        (None, Source::Default),
1031    ])
1032    .map_or((None, Source::Default), |(value, source)| {
1033        (Some(value), source)
1034    });
1035    let (style, style_source) = answered([
1036        (flags.style, Source::Flag),
1037        (config.and_then(|c| c.profile.release.style), Source::Config),
1038        (record.and_then(|r| r.profile.release.style), Source::Record),
1039        (None, Source::Observation),
1040        (
1041            (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
1042                .then_some(Style::Trunk),
1043            Source::Default,
1044        ),
1045    ])
1046    .map_or((None, Source::Default), |(value, source)| {
1047        (Some(value), source)
1048    });
1049    let (line_prefix, prefix_source) = answered([
1050        (None, Source::Flag),
1051        (
1052            config.and_then(|c| c.profile.release.line_prefix.clone()),
1053            Source::Config,
1054        ),
1055        (
1056            record.and_then(|r| r.profile.release.line_prefix.clone()),
1057            Source::Record,
1058        ),
1059        (None, Source::Observation),
1060        (
1061            (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1062            Source::Default,
1063        ),
1064    ])
1065    .map_or((None, Source::Default), |(value, source)| {
1066        (Some(value), source)
1067    });
1068
1069    // A reporting purpose never refuses what it can state: `rk profile`
1070    // and every preview report an intent the target cannot yet take, and
1071    // the capability catalog says why. A purpose that writes refuses,
1072    // because a record must not claim a release nothing can land.
1073    let reporting = purpose == Purpose::Preview;
1074    let release = match mode {
1075        ReleaseMode::Automatic => {
1076            if let Some(Proposal::Ambiguous { drivers }) = &proposal
1077                && driver.is_none()
1078                && !reporting
1079            {
1080                return Err(RkError::Usage(format!(
1081                    "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1082                    drivers.join(" and "),
1083                    crate::config::CONFIG_PATH
1084                )));
1085            }
1086            if forge.is_none() && !reporting {
1087                let message = observed.host.map_or_else(
1088                    || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1089                    |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1090                );
1091                return Err(RkError::refusal(
1092                    Diagnostic::new(Reason::ForgeUndetected, message)
1093                        .expected("a github.com or gitlab remote, or --forge")
1094                        .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1095                ));
1096            }
1097            if driver.is_none() && !reporting {
1098                return Err(invalid_release(format!(
1099                    "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1100                    known_drivers.join("|")
1101                )));
1102            }
1103            if let Some(driver) = &driver
1104                && !technologies.contains(driver)
1105                && !reporting
1106            {
1107                return Err(invalid_release(format!(
1108                    "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1109                    if technologies.is_empty() {
1110                        "empty".to_owned()
1111                    } else {
1112                        technologies.join(", ")
1113                    }
1114                )));
1115            }
1116            let style = match (style, purpose) {
1117                (None, Purpose::Upgrade | Purpose::Adopt) => {
1118                    return Err(RkError::Usage(
1119                        "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1120                    ));
1121                }
1122                (None, _) => Style::Trunk,
1123                (Some(style), _) => style,
1124            };
1125            sources.insert("profile.release.driver", driver_source);
1126            sources.insert("profile.release.style", style_source);
1127            sources.insert("profile.release.line_prefix", prefix_source);
1128            ReleaseIntent {
1129                mode,
1130                driver,
1131                style: Some(style),
1132                line_prefix: Some(
1133                    line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1134                ),
1135            }
1136        }
1137        ReleaseMode::External | ReleaseMode::None => {
1138            // A stated value under a mode that has no room for it is a
1139            // malformed intent. A value the mode outranks is not: that is
1140            // ordinary precedence, and `--release-mode none` over a
1141            // configured automatic release is the one command that retires
1142            // it. So the refusal fires only where the subordinate value
1143            // speaks at or above the tier that chose the mode.
1144            for (key, present, value_source) in [
1145                ("profile.release.driver", driver.is_some(), driver_source),
1146                ("profile.release.style", style.is_some(), style_source),
1147                (
1148                    "profile.release.line_prefix",
1149                    line_prefix.is_some(),
1150                    prefix_source,
1151                ),
1152            ] {
1153                if present
1154                    && matches!(value_source, Source::Flag | Source::Config)
1155                    && value_source.rank() <= mode_source.rank()
1156                {
1157                    return Err(invalid_release(format!(
1158                        "{key} is set while profile.release.mode is {}",
1159                        mode.as_str()
1160                    )));
1161                }
1162            }
1163            ReleaseIntent {
1164                mode,
1165                driver: None,
1166                style: None,
1167                line_prefix: None,
1168            }
1169        }
1170    };
1171
1172    // Every capability this binary ships for a forge renders the project
1173    // path, so the identity is required exactly where the forge has an
1174    // adapter. An unknown forge selects no such capability and needs none.
1175    let adapter_known = forge
1176        .as_deref()
1177        .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1178    let repo = match (forge.is_some(), adapter_known, repo) {
1179        // No forge at all: the identity has nowhere to point, so a lower
1180        // tier's remote or record must not survive into `[project]`.
1181        (false, _, _) => String::new(),
1182        (true, false, repo) => repo.unwrap_or_default(),
1183        (true, true, Some(repo)) => repo,
1184        (true, true, None) if purpose == Purpose::Preview => {
1185            crate::projection::REPO_PLACEHOLDER.to_owned()
1186        }
1187        (true, true, None) => return Err(crate::landing::repo_unresolved()),
1188    };
1189
1190    // The Git workflow.
1191    let (trunk, source) = answered([
1192        (flags.trunk.map(str::to_owned), Source::Flag),
1193        (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1194        (record.map(|r| r.git.trunk.clone()), Source::Record),
1195        (None, Source::Observation),
1196        (
1197            Some(crate::config::TRUNK_DEFAULT.to_owned()),
1198            Source::Default,
1199        ),
1200    ])
1201    .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1202    sources.insert("git.trunk", source);
1203    let (checkout_mode, source) = answered([
1204        (flags.checkout_mode, Source::Flag),
1205        (config.and_then(|c| c.git.checkout_mode), Source::Config),
1206        (record.map(|r| r.git.checkout_mode), Source::Record),
1207        (None, Source::Observation),
1208        (
1209            Some(if purpose == Purpose::Adopt {
1210                CheckoutMode::MainWorktree
1211            } else {
1212                CheckoutMode::LinkedWorktree
1213            }),
1214            Source::Default,
1215        ),
1216    ])
1217    .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1218    sources.insert("git.checkout_mode", source);
1219    // The compiled default is `local` and the record's absence answers
1220    // `forge`. The two differ deliberately: a fresh landing takes the
1221    // cheaper authority, and a target that landed before this axis
1222    // existed keeps the one whose blocks and protections it carries.
1223    let (integration, source) = answered([
1224        (flags.integration, Source::Flag),
1225        (config.and_then(|c| c.git.integration), Source::Config),
1226        (record.map(|r| r.git.integration), Source::Record),
1227        (None, Source::Observation),
1228        // An adoption defaults to `forge`, the way it defaults to the
1229        // main worktree: it is describing a target that already exists,
1230        // and every target that landed before this axis carries the
1231        // forge blocks. A fresh landing takes `local`.
1232        (
1233            Some(if purpose == Purpose::Adopt {
1234                Integration::Forge
1235            } else {
1236                Integration::Local
1237            }),
1238            Source::Default,
1239        ),
1240    ])
1241    .unwrap_or((Integration::Local, Source::Default));
1242    sources.insert("git.integration", source);
1243    // The merge form defaults to `squash` for every purpose: it is the
1244    // form every target took before the parameter existed, so a fresh
1245    // landing, an adoption, and an older record all answer the same.
1246    let (merge_form, source) = answered([
1247        (flags.merge_form, Source::Flag),
1248        (config.and_then(|c| c.git.merge_form), Source::Config),
1249        (record.map(|r| r.git.merge_form), Source::Record),
1250        (None, Source::Observation),
1251        (Some(manifest::merge_form_squash()), Source::Default),
1252    ])
1253    .unwrap_or((MergeForm::Squash, Source::Default));
1254    sources.insert("git.merge_form", source);
1255
1256    // The capability requests.
1257    let (nix_packaging, source) = answered([
1258        (flags.nix, Source::Flag),
1259        (
1260            config.and_then(|c| c.capabilities.nix_packaging),
1261            Source::Config,
1262        ),
1263        (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1264        (None, Source::Observation),
1265        (Some(false), Source::Default),
1266    ])
1267    .unwrap_or((false, Source::Default));
1268    sources.insert("capabilities.nix_packaging", source);
1269    let (reporting_policy, source) = answered([
1270        (flags.reporting_policy, Source::Flag),
1271        (
1272            config.and_then(|c| c.capabilities.reporting_policy),
1273            Source::Config,
1274        ),
1275        (
1276            record.map(|r| r.capabilities.reporting_policy),
1277            Source::Record,
1278        ),
1279        (None, Source::Observation),
1280        // A project that automates its release carries the policy by
1281        // default; a release-less profile asks for it explicitly.
1282        (
1283            Some(release.mode == ReleaseMode::Automatic),
1284            Source::Default,
1285        ),
1286    ])
1287    .unwrap_or((false, Source::Default));
1288    sources.insert("capabilities.reporting_policy", source);
1289    let (scorecard, source) = answered([
1290        (flags.scorecard, Source::Flag),
1291        (
1292            config.and_then(|c| c.capabilities.scorecard),
1293            Source::Config,
1294        ),
1295        (record.map(|r| r.capabilities.scorecard), Source::Record),
1296        (None, Source::Observation),
1297        (Some(false), Source::Default),
1298    ])
1299    .unwrap_or((false, Source::Default));
1300    sources.insert("capabilities.scorecard", source);
1301    let configured_scanning = config
1302        .and_then(|c| c.capabilities.code_scanning.as_deref())
1303        .map(Provider::parse)
1304        .transpose()?;
1305    let (code_scanning, source) = answered([
1306        (flags.code_scanning, Source::Flag),
1307        (configured_scanning, Source::Config),
1308        (record.map(|r| r.capabilities.code_scanning), Source::Record),
1309        (None, Source::Observation),
1310        (Some(None), Source::Default),
1311    ])
1312    .unwrap_or((None, Source::Default));
1313    sources.insert("capabilities.code_scanning", source);
1314    // The workflow guards are off for a fresh landing. A record that
1315    // predates the capability carries the pending answer, which keeps
1316    // them: no record is stripped of guards it landed without a question.
1317    // An adoption states them with its flag where the target carries them.
1318    let (workflow_guards, source) = answered([
1319        (flags.workflow_guards, Source::Flag),
1320        (
1321            config.and_then(|c| c.capabilities.workflow_guards),
1322            Source::Config,
1323        ),
1324        (
1325            record.map(|r| r.capabilities.workflow_guards()),
1326            Source::Record,
1327        ),
1328        (None, Source::Observation),
1329        (Some(false), Source::Default),
1330    ])
1331    .unwrap_or((false, Source::Default));
1332    sources.insert("capabilities.workflow_guards", source);
1333    // A requested scanner this release cannot land at these dimensions is
1334    // an unavailable optional capability, not a malformed request. The
1335    // catalog reports it and the landing omits it, which is what
1336    // `project-profile:an-operation-refuses-only-what-it-requires` says
1337    // must happen: only the selected release automation blocks an apply.
1338
1339    // The security policy's two answers.
1340    let (security_contact, source) = answered([
1341        (None, Source::Flag),
1342        (
1343            config.and_then(|c| c.security.contact.clone()),
1344            Source::Config,
1345        ),
1346        (
1347            record.map(|r| r.parameters.security_contact.clone()),
1348            Source::Record,
1349        ),
1350        (None, Source::Observation),
1351        (Some(String::new()), Source::Default),
1352    ])
1353    .unwrap_or((String::new(), Source::Default));
1354    let security_contact =
1355        crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1356    sources.insert("security.contact", source);
1357    let (security_response, source) = answered([
1358        (None, Source::Flag),
1359        (
1360            config.and_then(|c| c.security.response.clone()),
1361            Source::Config,
1362        ),
1363        (
1364            record.map(|r| r.parameters.security_response.clone()),
1365            Source::Record,
1366        ),
1367        (None, Source::Observation),
1368        (
1369            Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1370            Source::Default,
1371        ),
1372    ])
1373    .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1374    let security_response =
1375        crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1376    sources.insert("security.response", source);
1377
1378    // The release gate's two answers. Neither has an observation: nothing
1379    // in the target proposes a name. The compiled default is the
1380    // convention's own pair, and it applies to the one shape that renders
1381    // a gate reading them.
1382    let gate_default = gate_defaults(
1383        &ProfileSnapshot {
1384            technologies: technologies.clone(),
1385            forge: forge.clone(),
1386            release: release.clone(),
1387        },
1388        &GitWorkflow {
1389            trunk: trunk.clone(),
1390            checkout_mode,
1391            integration,
1392            merge_form,
1393        },
1394    );
1395    let (required_check, source) = answered([
1396        (flags.required_check.map(str::to_owned), Source::Flag),
1397        (
1398            config
1399                .map(|c| c.setup.required_check.clone())
1400                .filter(|name| !name.is_empty()),
1401            Source::Config,
1402        ),
1403        (
1404            record
1405                .map(|r| r.parameters.required_check.clone())
1406                .filter(|name| !name.is_empty()),
1407            Source::Record,
1408        ),
1409        (None, Source::Observation),
1410        (
1411            Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1412            Source::Default,
1413        ),
1414    ])
1415    .unwrap_or((String::new(), Source::Default));
1416    sources.insert("setup.required_check", source);
1417    let (required_workflow, source) = answered([
1418        (flags.required_workflow.map(str::to_owned), Source::Flag),
1419        (
1420            config
1421                .map(|c| c.setup.required_workflow.clone())
1422                .filter(|name| !name.is_empty()),
1423            Source::Config,
1424        ),
1425        (
1426            record
1427                .map(|r| r.parameters.required_workflow.clone())
1428                .filter(|name| !name.is_empty()),
1429            Source::Record,
1430        ),
1431        (None, Source::Observation),
1432        (
1433            Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1434            Source::Default,
1435        ),
1436    ])
1437    .unwrap_or((String::new(), Source::Default));
1438    sources.insert("setup.required_workflow", source);
1439    // The development environment has no flag and no observation: only the
1440    // operator answers it, and nothing reads a manager file to propose one.
1441    // A table whose run is empty states no answer and clears the record's.
1442    let recorded = || {
1443        record
1444            .and_then(|r| r.parameters.dev_environment.clone())
1445            .map_or((None, Source::Default), |answer| {
1446                (Some(answer), Source::Record)
1447            })
1448    };
1449    let (dev_environment, source) = config
1450        .and_then(|c| c.dev_environment.clone())
1451        .map_or_else(recorded, |answer| (answer.answered(), Source::Config));
1452    if let Some(answer) = &dev_environment {
1453        answer
1454            .check_forge(forge.as_deref())
1455            .map_err(crate::config::invalid)?;
1456    }
1457    sources.insert("dev_environment", source);
1458    // GitLab requires the whole pipeline through one project setting and
1459    // names no individual check, so neither answer has a reader there.
1460    // Refusing the flag says that; discarding it silently would let an
1461    // operator believe a gate was configured.
1462    if forge.as_deref() == Some("gitlab") {
1463        for (flag, supplied) in [
1464            ("--required-check", flags.required_check),
1465            ("--required-workflow", flags.required_workflow),
1466        ] {
1467            if supplied.is_some() {
1468                return Err(RkError::Usage(format!(
1469                    "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1470                )));
1471            }
1472        }
1473    }
1474
1475    let mut unknown: Vec<String> = technologies
1476        .iter()
1477        .filter(|name| !known_drivers.contains(name))
1478        .map(|name| format!("technology {name}"))
1479        .collect();
1480    if let Some(name) = &forge
1481        && crate::detect::Forge::parse(name).is_none()
1482    {
1483        unknown.push(format!("forge {name}"));
1484    }
1485
1486    Ok(Resolved {
1487        params: Params {
1488            profile: ProfileSnapshot {
1489                technologies,
1490                forge,
1491                release,
1492            },
1493            git: GitWorkflow {
1494                trunk,
1495                checkout_mode,
1496                integration,
1497                merge_form,
1498            },
1499            capabilities: CapabilityRequests {
1500                nix_packaging,
1501                reporting_policy,
1502                scorecard,
1503                code_scanning,
1504                workflow_guards: Some(workflow_guards),
1505            },
1506            repo,
1507            security_contact,
1508            security_response,
1509            required_check,
1510            required_workflow,
1511            dev_environment,
1512        },
1513        sources,
1514        unknown,
1515        proposal,
1516    })
1517}