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