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