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