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