Skip to main content

release_kit/
profile.rs

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