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