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