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::diagnostic::{Diagnostic, Reason};
28use crate::error::RkError;
29use crate::landing::manifest::{self, CheckoutMode, Integration, Provider, Style};
30
31/// The release intent's mode.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ReleaseMode {
35    /// release-kit drives the release: a bot maintains the request, and
36    /// the landed automation tags and publishes.
37    Automatic,
38    /// The target releases through a process release-kit does not drive.
39    /// No automation lands, and no bot-operate chapter applies.
40    External,
41    /// Nothing releases.
42    None,
43}
44
45impl ReleaseMode {
46    /// The flag, wire, and report form.
47    #[must_use]
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::Automatic => "automatic",
51            Self::External => "external",
52            Self::None => "none",
53        }
54    }
55
56    /// Parse a `--release-mode` flag value.
57    ///
58    /// # Errors
59    ///
60    /// Returns [`RkError::Usage`] naming the three values.
61    pub fn parse(raw: &str) -> Result<Self, RkError> {
62        match raw {
63            "automatic" => Ok(Self::Automatic),
64            "external" => Ok(Self::External),
65            "none" => Ok(Self::None),
66            other => Err(RkError::Usage(format!(
67                "unknown release mode '{other}'; the modes are: automatic, external, none"
68            ))),
69        }
70    }
71}
72
73/// The release intent: the mode and, for an automatic release, its driver,
74/// style, and line prefix.
75///
76/// SATISFIES project-profile:release-intent-has-three-modes
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ReleaseIntent {
79    /// The mode.
80    pub mode: ReleaseMode,
81    /// The technology that states the version and takes the bot; present
82    /// for an automatic release alone.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub driver: Option<String>,
85    /// The release style; present for an automatic release alone.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub style: Option<Style>,
88    /// The release-line branch prefix; present for an automatic release
89    /// alone.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub line_prefix: Option<String>,
92}
93
94/// What the project is, on the wire: values alone.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct ProfileSnapshot {
97    /// The technologies present, sorted, zero or many.
98    pub technologies: Vec<String>,
99    /// The forge, where the project has one.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub forge: Option<String>,
102    /// The release intent.
103    pub release: ReleaseIntent,
104}
105
106/// The Git workflow parameters.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct GitWorkflow {
109    /// The one permanent branch.
110    pub trunk: String,
111    /// Where a topic branch opens.
112    pub checkout_mode: CheckoutMode,
113    /// Which authority moves an implementation onto the trunk. A record
114    /// predating the parameter carries no key and reads as `forge`.
115    #[serde(default = "crate::landing::manifest::integration_forge")]
116    pub integration: Integration,
117}
118
119/// The optional products the target requested.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct CapabilityRequests {
122    /// The seeded package expression and the seed flake pair.
123    #[serde(default)]
124    pub nix_packaging: bool,
125    /// The landed vulnerability reporting policy.
126    #[serde(default)]
127    pub reporting_policy: bool,
128    /// The `OpenSSF` Scorecard workflow.
129    #[serde(default)]
130    pub scorecard: bool,
131    /// The code scanning workflow, by provider.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub code_scanning: Option<Provider>,
134}
135
136/// Where a resolved value came from.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
138#[serde(rename_all = "lowercase")]
139pub enum Source {
140    /// An invocation flag.
141    Flag,
142    /// The committed configuration.
143    Config,
144    /// A compatible landing record.
145    Record,
146    /// The target's observation: its version files and its origin remote.
147    Observation,
148    /// A compiled default.
149    Default,
150}
151
152impl Source {
153    /// Where this source sits in the one precedence, lowest first.
154    ///
155    /// The comparison a resolution needs when one field's answer has to be
156    /// weighed against another's: a value only contradicts a decision that
157    /// its own tier or a lower one made.
158    #[must_use]
159    pub const fn rank(self) -> u8 {
160        match self {
161            Self::Flag => 0,
162            Self::Config => 1,
163            Self::Record => 2,
164            Self::Observation => 3,
165            Self::Default => 4,
166        }
167    }
168
169    /// The report form.
170    #[must_use]
171    pub const fn as_str(self) -> &'static str {
172        match self {
173            Self::Flag => "flag",
174            Self::Config => "config",
175            Self::Record => "record",
176            Self::Observation => "observation",
177            Self::Default => "default",
178        }
179    }
180}
181
182/// What the observation proposes for the release, where nothing else
183/// answered it.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "kebab-case", tag = "state")]
186pub enum Proposal {
187    /// No release-bearing technology: nothing to automate.
188    None,
189    /// Exactly one release-bearing technology drives the release.
190    Automatic {
191        /// The driver.
192        driver: String,
193    },
194    /// More than one release-bearing technology, so a flag must name the
195    /// driver before an apply.
196    Ambiguous {
197        /// The candidates, sorted.
198        drivers: Vec<String>,
199    },
200}
201
202/// The complete resolved input to a projection, on the wire.
203///
204/// The values the configuration writes back, the record carries, and the
205/// projection renders from, with no precedence source.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct Params {
208    profile: ProfileSnapshot,
209    git: GitWorkflow,
210    capabilities: CapabilityRequests,
211    repo: String,
212    security_contact: String,
213    security_response: String,
214}
215
216/// Explicit invocation answers; absence falls through to configuration.
217#[derive(Default)]
218pub struct Inputs<'a> {
219    /// The technologies, replacing the declared list whole; empty is
220    /// unsupplied.
221    pub technologies: &'a [String],
222    /// Forge override.
223    pub forge: Option<&'a str>,
224    /// Repository override.
225    pub repo: Option<&'a str>,
226    /// Release mode override.
227    pub release_mode: Option<ReleaseMode>,
228    /// Release driver override.
229    pub release_driver: Option<&'a str>,
230    /// Release style override.
231    pub style: Option<Style>,
232    /// Trunk override.
233    pub trunk: Option<&'a str>,
234    /// Checkout mode override.
235    pub checkout_mode: Option<CheckoutMode>,
236    /// Integration mode override.
237    pub integration: Option<Integration>,
238    /// Nix packaging request override.
239    pub nix: Option<bool>,
240    /// Reporting policy request override.
241    pub reporting_policy: Option<bool>,
242    /// Scorecard request override.
243    pub scorecard: Option<bool>,
244    /// Code scanning override: `Some(None)` turns it off, and absence
245    /// leaves the configuration and the record to answer.
246    pub code_scanning: Option<Option<Provider>>,
247}
248
249/// Compatibility policy for a landing candidate.
250#[derive(Clone, Copy, PartialEq, Eq)]
251pub enum Purpose {
252    /// A first landing.
253    Init,
254    /// A preview may leave the repository unresolved and reports an
255    /// ambiguous release proposal rather than refusing it.
256    Preview,
257    /// An existing record supplies compatibility answers.
258    Upgrade,
259    /// A pre-record target requires an explicit release style.
260    Adopt,
261}
262
263/// The resolved target configuration, with the source of every value.
264#[derive(Debug, Clone)]
265pub struct Resolved {
266    /// The values.
267    pub params: Params,
268    /// The source of each value, keyed by its configuration path.
269    pub sources: BTreeMap<&'static str, Source>,
270    /// The category names the catalog does not know, preserved.
271    pub unknown: Vec<String>,
272    /// What the observation proposed for the release, where the mode was
273    /// not answered above it.
274    pub proposal: Option<Proposal>,
275}
276
277/// The canonical form of a category name, or why it is refused.
278///
279/// Lowercase, matching `[a-z0-9][a-z0-9-]*`. An unknown name is preserved, so the
280/// shape is what keeps a record and a configuration readable.
281///
282/// SATISFIES project-profile:an-unknown-category-is-preserved
283///
284/// # Errors
285/// The refusal text, naming the value and the shape.
286pub fn canonical_category(raw: &str) -> Result<String, String> {
287    let lowered = raw.trim().to_ascii_lowercase();
288    let shaped = lowered
289        .chars()
290        .next()
291        .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
292        && lowered
293            .chars()
294            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
295    if shaped {
296        Ok(lowered)
297    } else {
298        Err(format!(
299            "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
300        ))
301    }
302}
303
304/// A list of category names canonicalized, refused on a duplicate, and
305/// sorted.
306///
307/// # Errors
308/// The refusal text, naming the key.
309pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
310    let mut out = Vec::with_capacity(raw.len());
311    for value in raw {
312        let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
313        if out.contains(&name) {
314            return Err(format!("{key} names {name} twice; each technology once"));
315        }
316        out.push(name);
317    }
318    out.sort();
319    Ok(out)
320}
321
322impl Params {
323    /// Reconstruct every projection parameter from the record alone,
324    /// including the compatibility defaults applied when it was loaded.
325    #[must_use]
326    pub fn from_record(record: &manifest::Manifest) -> Self {
327        Self {
328            profile: record.profile.clone(),
329            git: record.git.clone(),
330            capabilities: record.capabilities.clone(),
331            repo: record.parameters.repo.clone(),
332            security_contact: record.parameters.security_contact.clone(),
333            security_response: record.parameters.security_response.clone(),
334        }
335    }
336
337    /// Resolve flags, configuration, recorded compatibility inputs or
338    /// observation, and finally the compiled defaults. Comparisons use
339    /// `from_record` alone.
340    ///
341    /// # Errors
342    /// Refuses unresolved identity, an invalid release state, or a style
343    /// an existing target has not answered.
344    pub fn resolve(
345        target: &Utf8Path,
346        flags: &Inputs<'_>,
347        config: Option<&crate::config::Config>,
348        record: Option<&manifest::Manifest>,
349        purpose: Purpose,
350    ) -> Result<Self, RkError> {
351        resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
352    }
353
354    /// What the project is.
355    #[must_use]
356    pub const fn profile(&self) -> &ProfileSnapshot {
357        &self.profile
358    }
359
360    /// The Git workflow parameters.
361    #[must_use]
362    pub const fn git(&self) -> &GitWorkflow {
363        &self.git
364    }
365
366    /// The capability requests.
367    #[must_use]
368    pub const fn capabilities(&self) -> &CapabilityRequests {
369        &self.capabilities
370    }
371
372    /// The technologies present, sorted.
373    #[must_use]
374    pub fn technologies(&self) -> &[String] {
375        &self.profile.technologies
376    }
377
378    /// The forge, where the project has one.
379    #[must_use]
380    pub fn forge(&self) -> Option<&str> {
381        self.profile.forge.as_deref()
382    }
383
384    /// The release mode.
385    #[must_use]
386    pub const fn release_mode(&self) -> ReleaseMode {
387        self.profile.release.mode
388    }
389
390    /// The release driver, for an automatic release.
391    #[must_use]
392    pub fn driver(&self) -> Option<&str> {
393        self.profile.release.driver.as_deref()
394    }
395
396    /// The release style, for an automatic release that answered it.
397    #[must_use]
398    pub const fn style(&self) -> Option<Style> {
399        self.profile.release.style
400    }
401
402    /// Whether this landing requested the Nix capability.
403    #[must_use]
404    pub const fn nix_packaging(&self) -> bool {
405        self.capabilities.nix_packaging
406    }
407
408    /// Whether this landing requested the reporting policy.
409    #[must_use]
410    pub const fn reporting_policy(&self) -> bool {
411        self.capabilities.reporting_policy
412    }
413
414    /// Whether this landing requested the Scorecard capability.
415    #[must_use]
416    pub const fn scorecard(&self) -> bool {
417        self.capabilities.scorecard
418    }
419
420    /// The code scanning provider this landing requested, if any.
421    #[must_use]
422    pub const fn code_scanning(&self) -> Option<Provider> {
423        self.capabilities.code_scanning
424    }
425
426    /// The project path used by parameter-bearing files, empty where the
427    /// target has no forge repository.
428    #[must_use]
429    pub fn repo(&self) -> &str {
430        &self.repo
431    }
432
433    /// Where a topic branch opens.
434    #[must_use]
435    pub const fn checkout_mode(&self) -> CheckoutMode {
436        self.git.checkout_mode
437    }
438
439    /// Which authority moves an implementation onto the trunk.
440    #[must_use]
441    pub const fn integration(&self) -> Integration {
442        self.git.integration
443    }
444
445    /// The one permanent branch this landing writes into its artifacts.
446    #[must_use]
447    pub fn trunk(&self) -> &str {
448        &self.git.trunk
449    }
450
451    /// The release-line prefix this landing writes into its artifacts,
452    /// the compiled default where the release intent carries none.
453    #[must_use]
454    pub fn line_prefix(&self) -> &str {
455        self.profile
456            .release
457            .line_prefix
458            .as_deref()
459            .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
460    }
461
462    /// The contact the landed policy names, empty for the forge's own
463    /// authored wording.
464    #[must_use]
465    pub fn security_contact(&self) -> &str {
466        &self.security_contact
467    }
468
469    /// The acknowledgment window the landed policy promises.
470    #[must_use]
471    pub fn security_response(&self) -> &str {
472        &self.security_response
473    }
474
475    /// The canonical identity and Git workflow flags, as `rk init` and
476    /// `rk adopt` take them: every resolved answer stated, so a follow-up
477    /// command a preview prints applies the decision that was previewed.
478    #[must_use]
479    pub fn canonical_flags(&self) -> String {
480        let mut out = String::new();
481        for technology in &self.profile.technologies {
482            out.push_str(" --technology ");
483            out.push_str(technology);
484        }
485        if let Some(forge) = &self.profile.forge {
486            out.push_str(" --forge ");
487            out.push_str(forge);
488        }
489        // A preview stands in for an unresolved repository with the
490        // placeholder, and a replayed apply takes the operator's own path
491        // rather than that stand-in, so the flag stays out of the command.
492        if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
493            out.push_str(" --repo ");
494            out.push_str(&self.repo);
495        }
496        out.push_str(" --release-mode ");
497        out.push_str(self.profile.release.mode.as_str());
498        if let Some(driver) = &self.profile.release.driver {
499            out.push_str(" --release-driver ");
500            out.push_str(driver);
501        }
502        if let Some(style) = self.profile.release.style {
503            out.push_str(" --release-style ");
504            out.push_str(style.as_str());
505        }
506        out.push_str(" --trunk ");
507        out.push_str(&self.git.trunk);
508        out.push_str(" --checkout-mode ");
509        out.push_str(self.git.checkout_mode.as_str());
510        out.push_str(" --integration ");
511        out.push_str(self.git.integration.as_str());
512        out
513    }
514
515    /// Every opt-in capability's flag, as `rk init` and `rk adopt` take it.
516    ///
517    /// The resolved answers, not the flags the caller typed: a follow-up
518    /// command a preview prints must apply the decision that was previewed,
519    /// and the preview's decision is what resolution produced.
520    #[must_use]
521    pub fn capability_flags(&self) -> String {
522        let mut out = String::new();
523        if self.capabilities.nix_packaging {
524            out.push_str(" --nix-packaging");
525        }
526        if self.capabilities.reporting_policy {
527            out.push_str(" --reporting-policy");
528        }
529        if self.capabilities.scorecard {
530            out.push_str(" --scorecard");
531        }
532        // The provider flag takes a value, so `off` is a statable answer and
533        // is stated: a committed `capabilities.code_scanning` would
534        // otherwise re-enable on replay exactly what this preview turned
535        // off. The boolean flags above have no off form, so absence is
536        // their only honest rendering and no committed value can
537        // contradict it.
538        out.push_str(" --code-scanning ");
539        out.push_str(
540            self.capabilities
541                .code_scanning
542                .map_or("off", Provider::as_str),
543        );
544        out
545    }
546
547    /// The same answers as `rk upgrade` takes them, every one stated.
548    ///
549    /// An upgrade can turn a capability off as well as on, so absence is no
550    /// answer there and each value is rendered explicitly. That is what makes
551    /// a printed follow-up command reproduce the previewed decision rather
552    /// than re-resolve the configured one.
553    #[must_use]
554    pub fn capability_toggles(&self) -> String {
555        let word = |on: bool| if on { "on" } else { "off" };
556        format!(
557            " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {}",
558            word(self.capabilities.nix_packaging),
559            word(self.capabilities.reporting_policy),
560            word(self.capabilities.scorecard),
561            self.capabilities
562                .code_scanning
563                .map_or("off", Provider::as_str)
564        )
565    }
566}
567
568#[cfg(test)]
569impl Params {
570    /// A parameter set for tests alone: an automatic rust release on
571    /// GitHub. Production code reaches `Params` through `from_record` and
572    /// `resolve` and through nothing else, and this constructor is
573    /// compiled out of the shipped binary.
574    pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
575        Self {
576            profile: ProfileSnapshot {
577                technologies: vec!["rust".to_owned()],
578                forge: Some("github".to_owned()),
579                release: ReleaseIntent {
580                    mode: ReleaseMode::Automatic,
581                    driver: Some("rust".to_owned()),
582                    style,
583                    line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
584                },
585            },
586            git: GitWorkflow {
587                trunk: crate::config::TRUNK_DEFAULT.to_owned(),
588                checkout_mode: CheckoutMode::LinkedWorktree,
589                integration: Integration::Local,
590            },
591            capabilities: CapabilityRequests {
592                nix_packaging: false,
593                reporting_policy: true,
594                scorecard: false,
595                code_scanning: None,
596            },
597            repo: repo.to_owned(),
598            security_contact: String::new(),
599            security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
600        }
601    }
602
603    /// The same set with the two security parameters answered.
604    pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
605        Self {
606            security_contact: contact.to_owned(),
607            security_response: response.to_owned(),
608            ..Self::for_test("acme/widget", Some(Style::Trunk))
609        }
610    }
611
612    /// A release-less set for tests: the technologies and the forge as
613    /// given, no driver, no style.
614    pub(crate) fn for_test_release_less(
615        technologies: &[&str],
616        forge: Option<&str>,
617        mode: ReleaseMode,
618    ) -> Self {
619        let mut params = Self::for_test("acme/widget", None);
620        params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
621        params.profile.forge = forge.map(str::to_owned);
622        params.profile.release = ReleaseIntent {
623            mode,
624            driver: None,
625            style: None,
626            line_prefix: None,
627        };
628        params.capabilities.reporting_policy = false;
629        if forge.is_none() {
630            params.repo = String::new();
631        }
632        params
633    }
634
635    /// The same set with the forge and the driver changed.
636    pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
637        self.profile.technologies = vec![driver.to_owned()];
638        self.profile.release.driver = Some(driver.to_owned());
639        self.profile.forge = Some(forge.to_owned());
640    }
641
642    /// The same set with the checkout mode answered.
643    pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
644        self.git.checkout_mode = mode;
645    }
646
647    /// The same set with the integration mode answered.
648    pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
649        self.git.integration = mode;
650    }
651
652    /// The same set with the Nix opt-in answered.
653    pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
654        self.capabilities.nix_packaging = nix;
655    }
656
657    /// The same set with the Scorecard opt-in answered.
658    pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
659        self.capabilities.scorecard = scorecard;
660    }
661
662    /// The same set with the code scanning provider answered.
663    pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
664        self.capabilities.code_scanning = provider;
665    }
666}
667
668/// The refusal for an invalid release state, naming the key and its
669/// valid shape.
670fn invalid_release(message: impl std::fmt::Display) -> RkError {
671    RkError::Usage(format!(
672        "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
673    ))
674}
675
676/// One field's answer and where it came from.
677fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
678    chain
679        .into_iter()
680        .find_map(|(value, source)| value.map(|value| (value, source)))
681}
682
683/// Resolve every domain value by one precedence, keeping the source of
684/// each.
685///
686/// # Errors
687/// Refuses unresolved identity, an invalid release state, a duplicate or
688/// malformed category name, and a style an existing target has not
689/// answered.
690#[allow(
691    clippy::too_many_lines,
692    reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
693)]
694pub fn resolve(
695    target: &Utf8Path,
696    flags: &Inputs<'_>,
697    config: Option<&crate::config::Config>,
698    record: Option<&manifest::Manifest>,
699    purpose: Purpose,
700) -> Result<Resolved, RkError> {
701    let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
702    let observed = crate::detect::observe(target.as_std_path());
703    let known_drivers = catalog::known_drivers();
704
705    // Technologies: a supplied list replaces the declared one whole.
706    let (technologies, source) = answered([
707        (
708            (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
709            Source::Flag,
710        ),
711        (
712            config.and_then(|c| c.profile.technologies.clone()),
713            Source::Config,
714        ),
715        (
716            record.map(|r| r.profile.technologies.clone()),
717            Source::Record,
718        ),
719        (
720            Some(
721                observed
722                    .technologies
723                    .iter()
724                    .map(|t| (*t).to_owned())
725                    .collect(),
726            ),
727            Source::Observation,
728        ),
729        (None, Source::Default),
730    ])
731    .unwrap_or_else(|| (Vec::new(), Source::Default));
732    let technologies =
733        canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
734    sources.insert("profile.technologies", source);
735
736    // The forge: an explicit empty configuration value states no forge.
737    let forge_flag = flags
738        .forge
739        .map(|name| canonical_category(name).map_err(RkError::Usage))
740        .transpose()?;
741    let (forge, source) = answered([
742        (forge_flag.map(Some), Source::Flag),
743        (
744            config
745                .and_then(|c| c.profile.forge.clone())
746                .map(|value| if value.is_empty() { None } else { Some(value) }),
747            Source::Config,
748        ),
749        (record.map(|r| r.profile.forge.clone()), Source::Record),
750        (
751            observed.forge.map(|forge| Some(forge.as_str().to_owned())),
752            Source::Observation,
753        ),
754        (Some(None), Source::Default),
755    ])
756    .unwrap_or((None, Source::Default));
757    let forge = forge
758        .map(|name| canonical_category(&name).map_err(RkError::Usage))
759        .transpose()?;
760    sources.insert("profile.forge", source);
761
762    // The repository identity, needed only where a forge is present.
763    let (repo, source) = answered([
764        (flags.repo.map(str::to_owned), Source::Flag),
765        (
766            config
767                .map(|c| c.project.repo.clone())
768                .filter(|value| !value.is_empty()),
769            Source::Config,
770        ),
771        (
772            record
773                .map(|r| r.parameters.repo.clone())
774                .filter(|value| !value.is_empty()),
775            Source::Record,
776        ),
777        (observed.repo.clone(), Source::Observation),
778        (None, Source::Default),
779    ])
780    .map_or((None, Source::Default), |(value, source)| {
781        (Some(value), source)
782    });
783    sources.insert("project.repo", source);
784
785    // The release mode: the observation proposes where nothing above
786    // answers.
787    let release_bearing: Vec<String> = technologies
788        .iter()
789        .filter(|name| known_drivers.contains(name))
790        .cloned()
791        .collect();
792    let proposal = match release_bearing.as_slice() {
793        [] => Proposal::None,
794        [one] => Proposal::Automatic {
795            driver: one.clone(),
796        },
797        many => Proposal::Ambiguous {
798            drivers: many.to_vec(),
799        },
800    };
801    // The proposal reads the version files alone. A missing forge is not
802    // an answer about the release intent: it is a separate refusal the
803    // automatic branch below raises, naming the remote it did not find and
804    // the two ways out. Folding it in here would silently land a
805    // release-less target for a crate whose author simply has no remote
806    // yet, and the record would then claim a release intent nobody stated.
807    let proposed_mode = match &proposal {
808        Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
809        Proposal::None => ReleaseMode::None,
810    };
811    let (mode, source) = answered([
812        (flags.release_mode, Source::Flag),
813        (config.and_then(|c| c.profile.release.mode), Source::Config),
814        (record.map(|r| r.profile.release.mode), Source::Record),
815        (Some(proposed_mode), Source::Observation),
816        (None, Source::Default),
817    ])
818    .unwrap_or((ReleaseMode::None, Source::Default));
819    let mode_source = source;
820    sources.insert("profile.release.mode", mode_source);
821    let mode_answered_above = mode_source != Source::Observation;
822    let proposal = (!mode_answered_above).then_some(proposal);
823
824    // The driver, style, and line prefix belong to an automatic release
825    // alone, and a value from a flag or the configuration under another
826    // mode is a malformed intent rather than an ignored one.
827    let driver_flag = flags
828        .release_driver
829        .map(|name| canonical_category(name).map_err(RkError::Usage))
830        .transpose()?;
831    let (driver, driver_source) = answered([
832        (driver_flag, Source::Flag),
833        (
834            config.and_then(|c| c.profile.release.driver.clone()),
835            Source::Config,
836        ),
837        (
838            record.and_then(|r| r.profile.release.driver.clone()),
839            Source::Record,
840        ),
841        (
842            match &proposal {
843                Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
844                    Some(driver.clone())
845                }
846                _ => None,
847            },
848            Source::Observation,
849        ),
850        (None, Source::Default),
851    ])
852    .map_or((None, Source::Default), |(value, source)| {
853        (Some(value), source)
854    });
855    let (style, style_source) = answered([
856        (flags.style, Source::Flag),
857        (config.and_then(|c| c.profile.release.style), Source::Config),
858        (record.and_then(|r| r.profile.release.style), Source::Record),
859        (None, Source::Observation),
860        (
861            (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
862                .then_some(Style::Trunk),
863            Source::Default,
864        ),
865    ])
866    .map_or((None, Source::Default), |(value, source)| {
867        (Some(value), source)
868    });
869    let (line_prefix, prefix_source) = answered([
870        (None, Source::Flag),
871        (
872            config.and_then(|c| c.profile.release.line_prefix.clone()),
873            Source::Config,
874        ),
875        (
876            record.and_then(|r| r.profile.release.line_prefix.clone()),
877            Source::Record,
878        ),
879        (None, Source::Observation),
880        (
881            (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
882            Source::Default,
883        ),
884    ])
885    .map_or((None, Source::Default), |(value, source)| {
886        (Some(value), source)
887    });
888
889    // A reporting purpose never refuses what it can state: `rk profile`
890    // and every preview report an intent the target cannot yet take, and
891    // the capability catalog says why. A purpose that writes refuses,
892    // because a record must not claim a release nothing can land.
893    let reporting = purpose == Purpose::Preview;
894    let release = match mode {
895        ReleaseMode::Automatic => {
896            if let Some(Proposal::Ambiguous { drivers }) = &proposal
897                && driver.is_none()
898                && !reporting
899            {
900                return Err(RkError::Usage(format!(
901                    "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
902                    drivers.join(" and "),
903                    crate::config::CONFIG_PATH
904                )));
905            }
906            if forge.is_none() && !reporting {
907                let message = observed.host.map_or_else(
908                    || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
909                    |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
910                );
911                return Err(RkError::refusal(
912                    Diagnostic::new(Reason::ForgeUndetected, message)
913                        .expected("a github.com or gitlab remote, or --forge")
914                        .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
915                ));
916            }
917            if driver.is_none() && !reporting {
918                return Err(invalid_release(format!(
919                    "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
920                    known_drivers.join("|")
921                )));
922            }
923            if let Some(driver) = &driver
924                && !technologies.contains(driver)
925                && !reporting
926            {
927                return Err(invalid_release(format!(
928                    "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
929                    if technologies.is_empty() {
930                        "empty".to_owned()
931                    } else {
932                        technologies.join(", ")
933                    }
934                )));
935            }
936            let style = match (style, purpose) {
937                (None, Purpose::Upgrade | Purpose::Adopt) => {
938                    return Err(RkError::Usage(
939                        "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
940                    ));
941                }
942                (None, _) => Style::Trunk,
943                (Some(style), _) => style,
944            };
945            sources.insert("profile.release.driver", driver_source);
946            sources.insert("profile.release.style", style_source);
947            sources.insert("profile.release.line_prefix", prefix_source);
948            ReleaseIntent {
949                mode,
950                driver,
951                style: Some(style),
952                line_prefix: Some(
953                    line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
954                ),
955            }
956        }
957        ReleaseMode::External | ReleaseMode::None => {
958            // A stated value under a mode that has no room for it is a
959            // malformed intent. A value the mode outranks is not: that is
960            // ordinary precedence, and `--release-mode none` over a
961            // configured automatic release is the one command that retires
962            // it. So the refusal fires only where the subordinate value
963            // speaks at or above the tier that chose the mode.
964            for (key, present, value_source) in [
965                ("profile.release.driver", driver.is_some(), driver_source),
966                ("profile.release.style", style.is_some(), style_source),
967                (
968                    "profile.release.line_prefix",
969                    line_prefix.is_some(),
970                    prefix_source,
971                ),
972            ] {
973                if present
974                    && matches!(value_source, Source::Flag | Source::Config)
975                    && value_source.rank() <= mode_source.rank()
976                {
977                    return Err(invalid_release(format!(
978                        "{key} is set while profile.release.mode is {}",
979                        mode.as_str()
980                    )));
981                }
982            }
983            ReleaseIntent {
984                mode,
985                driver: None,
986                style: None,
987                line_prefix: None,
988            }
989        }
990    };
991
992    // Every capability this binary ships for a forge renders the project
993    // path, so the identity is required exactly where the forge has an
994    // adapter. An unknown forge selects no such capability and needs none.
995    let adapter_known = forge
996        .as_deref()
997        .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
998    let repo = match (forge.is_some(), adapter_known, repo) {
999        // No forge at all: the identity has nowhere to point, so a lower
1000        // tier's remote or record must not survive into `[project]`.
1001        (false, _, _) => String::new(),
1002        (true, false, repo) => repo.unwrap_or_default(),
1003        (true, true, Some(repo)) => repo,
1004        (true, true, None) if purpose == Purpose::Preview => {
1005            crate::projection::REPO_PLACEHOLDER.to_owned()
1006        }
1007        (true, true, None) => return Err(crate::landing::repo_unresolved()),
1008    };
1009
1010    // The Git workflow.
1011    let (trunk, source) = answered([
1012        (flags.trunk.map(str::to_owned), Source::Flag),
1013        (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1014        (record.map(|r| r.git.trunk.clone()), Source::Record),
1015        (None, Source::Observation),
1016        (
1017            Some(crate::config::TRUNK_DEFAULT.to_owned()),
1018            Source::Default,
1019        ),
1020    ])
1021    .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1022    sources.insert("git.trunk", source);
1023    let (checkout_mode, source) = answered([
1024        (flags.checkout_mode, Source::Flag),
1025        (config.and_then(|c| c.git.checkout_mode), Source::Config),
1026        (record.map(|r| r.git.checkout_mode), Source::Record),
1027        (None, Source::Observation),
1028        (
1029            Some(if purpose == Purpose::Adopt {
1030                CheckoutMode::MainWorktree
1031            } else {
1032                CheckoutMode::LinkedWorktree
1033            }),
1034            Source::Default,
1035        ),
1036    ])
1037    .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1038    sources.insert("git.checkout_mode", source);
1039    // The compiled default is `local` and the record's absence answers
1040    // `forge`. The two differ deliberately: a fresh landing takes the
1041    // cheaper authority, and a target that landed before this axis
1042    // existed keeps the one whose blocks and protections it carries.
1043    let (integration, source) = answered([
1044        (flags.integration, Source::Flag),
1045        (config.and_then(|c| c.git.integration), Source::Config),
1046        (record.map(|r| r.git.integration), Source::Record),
1047        (None, Source::Observation),
1048        // An adoption defaults to `forge`, the way it defaults to the
1049        // main worktree: it is describing a target that already exists,
1050        // and every target that landed before this axis carries the
1051        // forge blocks. A fresh landing takes `local`.
1052        (
1053            Some(if purpose == Purpose::Adopt {
1054                Integration::Forge
1055            } else {
1056                Integration::Local
1057            }),
1058            Source::Default,
1059        ),
1060    ])
1061    .unwrap_or((Integration::Local, Source::Default));
1062    sources.insert("git.integration", source);
1063
1064    // The capability requests.
1065    let (nix_packaging, source) = answered([
1066        (flags.nix, Source::Flag),
1067        (
1068            config.and_then(|c| c.capabilities.nix_packaging),
1069            Source::Config,
1070        ),
1071        (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1072        (None, Source::Observation),
1073        (Some(false), Source::Default),
1074    ])
1075    .unwrap_or((false, Source::Default));
1076    sources.insert("capabilities.nix_packaging", source);
1077    let (reporting_policy, source) = answered([
1078        (flags.reporting_policy, Source::Flag),
1079        (
1080            config.and_then(|c| c.capabilities.reporting_policy),
1081            Source::Config,
1082        ),
1083        (
1084            record.map(|r| r.capabilities.reporting_policy),
1085            Source::Record,
1086        ),
1087        (None, Source::Observation),
1088        // A project that automates its release carries the policy by
1089        // default; a release-less profile asks for it explicitly.
1090        (
1091            Some(release.mode == ReleaseMode::Automatic),
1092            Source::Default,
1093        ),
1094    ])
1095    .unwrap_or((false, Source::Default));
1096    sources.insert("capabilities.reporting_policy", source);
1097    let (scorecard, source) = answered([
1098        (flags.scorecard, Source::Flag),
1099        (
1100            config.and_then(|c| c.capabilities.scorecard),
1101            Source::Config,
1102        ),
1103        (record.map(|r| r.capabilities.scorecard), Source::Record),
1104        (None, Source::Observation),
1105        (Some(false), Source::Default),
1106    ])
1107    .unwrap_or((false, Source::Default));
1108    sources.insert("capabilities.scorecard", source);
1109    let configured_scanning = config
1110        .and_then(|c| c.capabilities.code_scanning.as_deref())
1111        .map(Provider::parse)
1112        .transpose()?;
1113    let (code_scanning, source) = answered([
1114        (flags.code_scanning, Source::Flag),
1115        (configured_scanning, Source::Config),
1116        (record.map(|r| r.capabilities.code_scanning), Source::Record),
1117        (None, Source::Observation),
1118        (Some(None), Source::Default),
1119    ])
1120    .unwrap_or((None, Source::Default));
1121    sources.insert("capabilities.code_scanning", source);
1122    // A requested scanner this release cannot land at these dimensions is
1123    // an unavailable optional capability, not a malformed request. The
1124    // catalog reports it and the landing omits it, which is what
1125    // `project-profile:an-operation-refuses-only-what-it-requires` says
1126    // must happen: only the selected release automation blocks an apply.
1127
1128    // The security policy's two answers.
1129    let (security_contact, source) = answered([
1130        (None, Source::Flag),
1131        (
1132            config.and_then(|c| c.security.contact.clone()),
1133            Source::Config,
1134        ),
1135        (
1136            record.map(|r| r.parameters.security_contact.clone()),
1137            Source::Record,
1138        ),
1139        (None, Source::Observation),
1140        (Some(String::new()), Source::Default),
1141    ])
1142    .unwrap_or((String::new(), Source::Default));
1143    let security_contact =
1144        crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1145    sources.insert("security.contact", source);
1146    let (security_response, source) = answered([
1147        (None, Source::Flag),
1148        (
1149            config.and_then(|c| c.security.response.clone()),
1150            Source::Config,
1151        ),
1152        (
1153            record.map(|r| r.parameters.security_response.clone()),
1154            Source::Record,
1155        ),
1156        (None, Source::Observation),
1157        (
1158            Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1159            Source::Default,
1160        ),
1161    ])
1162    .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1163    let security_response =
1164        crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1165    sources.insert("security.response", source);
1166
1167    let mut unknown: Vec<String> = technologies
1168        .iter()
1169        .filter(|name| !known_drivers.contains(name))
1170        .map(|name| format!("technology {name}"))
1171        .collect();
1172    if let Some(name) = &forge
1173        && crate::detect::Forge::parse(name).is_none()
1174    {
1175        unknown.push(format!("forge {name}"));
1176    }
1177
1178    Ok(Resolved {
1179        params: Params {
1180            profile: ProfileSnapshot {
1181                technologies,
1182                forge,
1183                release,
1184            },
1185            git: GitWorkflow {
1186                trunk,
1187                checkout_mode,
1188                integration,
1189            },
1190            capabilities: CapabilityRequests {
1191                nix_packaging,
1192                reporting_policy,
1193                scorecard,
1194                code_scanning,
1195            },
1196            repo,
1197            security_contact,
1198            security_response,
1199        },
1200        sources,
1201        unknown,
1202        proposal,
1203    })
1204}