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