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    required_check: String,
215    required_workflow: String,
216}
217
218/// Explicit invocation answers; absence falls through to configuration.
219#[derive(Default)]
220pub struct Inputs<'a> {
221    /// The technologies, replacing the declared list whole; empty is
222    /// unsupplied.
223    pub technologies: &'a [String],
224    /// Forge override.
225    pub forge: Option<&'a str>,
226    /// Repository override.
227    pub repo: Option<&'a str>,
228    /// Release mode override.
229    pub release_mode: Option<ReleaseMode>,
230    /// Release driver override.
231    pub release_driver: Option<&'a str>,
232    /// Release style override.
233    pub style: Option<Style>,
234    /// Trunk override.
235    pub trunk: Option<&'a str>,
236    /// Checkout mode override.
237    pub checkout_mode: Option<CheckoutMode>,
238    /// Integration mode override.
239    pub integration: Option<Integration>,
240    /// The check the release gate believes, overriding the configuration.
241    pub required_check: Option<&'a str>,
242    /// The workflow whose completion wakes the release gate, overriding
243    /// the configuration.
244    pub required_workflow: Option<&'a str>,
245    /// Nix packaging request override.
246    pub nix: Option<bool>,
247    /// Reporting policy request override.
248    pub reporting_policy: Option<bool>,
249    /// Scorecard request override.
250    pub scorecard: Option<bool>,
251    /// Code scanning override: `Some(None)` turns it off, and absence
252    /// leaves the configuration and the record to answer.
253    pub code_scanning: Option<Option<Provider>>,
254}
255
256/// Compatibility policy for a landing candidate.
257#[derive(Clone, Copy, PartialEq, Eq)]
258pub enum Purpose {
259    /// A first landing.
260    Init,
261    /// A preview may leave the repository unresolved and reports an
262    /// ambiguous release proposal rather than refusing it.
263    Preview,
264    /// An existing record supplies compatibility answers.
265    Upgrade,
266    /// A pre-record target requires an explicit release style.
267    Adopt,
268}
269
270/// The resolved target configuration, with the source of every value.
271#[derive(Debug, Clone)]
272pub struct Resolved {
273    /// The values.
274    pub params: Params,
275    /// The source of each value, keyed by its configuration path.
276    pub sources: BTreeMap<&'static str, Source>,
277    /// The category names the catalog does not know, preserved.
278    pub unknown: Vec<String>,
279    /// What the observation proposed for the release, where the mode was
280    /// not answered above it.
281    pub proposal: Option<Proposal>,
282}
283
284/// The canonical form of a category name, or why it is refused.
285///
286/// Lowercase, matching `[a-z0-9][a-z0-9-]*`. An unknown name is preserved, so the
287/// shape is what keeps a record and a configuration readable.
288///
289/// SATISFIES project-profile:an-unknown-category-is-preserved
290///
291/// # Errors
292/// The refusal text, naming the value and the shape.
293pub fn canonical_category(raw: &str) -> Result<String, String> {
294    let lowered = raw.trim().to_ascii_lowercase();
295    let shaped = lowered
296        .chars()
297        .next()
298        .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
299        && lowered
300            .chars()
301            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
302    if shaped {
303        Ok(lowered)
304    } else {
305        Err(format!(
306            "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
307        ))
308    }
309}
310
311/// A list of category names canonicalized, refused on a duplicate, and
312/// sorted.
313///
314/// # Errors
315/// The refusal text, naming the key.
316pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
317    let mut out = Vec::with_capacity(raw.len());
318    for value in raw {
319        let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
320        if out.contains(&name) {
321            return Err(format!("{key} names {name} twice; each technology once"));
322        }
323        out.push(name);
324    }
325    out.sort();
326    Ok(out)
327}
328
329/// Append one POSIX-shell word, leaving the common inert alphabet readable
330/// and single-quoting everything that could become syntax on replay.
331fn push_shell_word(out: &mut String, value: &str) {
332    let inert = !value.is_empty()
333        && value.bytes().all(|byte| {
334            byte.is_ascii_alphanumeric()
335                || matches!(
336                    byte,
337                    b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
338                )
339        });
340    if inert {
341        out.push_str(value);
342        return;
343    }
344    out.push('\'');
345    out.push_str(&value.replace('\'', "'\"'\"'"));
346    out.push('\'');
347}
348
349impl Params {
350    /// Reconstruct every projection parameter from the record alone,
351    /// including the compatibility defaults applied when it was loaded.
352    #[must_use]
353    pub fn from_record(record: &manifest::Manifest) -> Self {
354        Self {
355            profile: record.profile.clone(),
356            git: record.git.clone(),
357            capabilities: record.capabilities.clone(),
358            repo: record.parameters.repo.clone(),
359            security_contact: record.parameters.security_contact.clone(),
360            security_response: record.parameters.security_response.clone(),
361            // A record predating the two answers carries neither, and
362            // the compiled default is what such a landing renders now.
363            required_check: gate_answer(
364                record,
365                |(check, _)| check,
366                |parameters| &parameters.required_check,
367            ),
368            required_workflow: gate_answer(
369                record,
370                |(_, workflow)| workflow,
371                |parameters| &parameters.required_workflow,
372            ),
373        }
374    }
375
376    /// Resolve flags, configuration, recorded compatibility inputs or
377    /// observation, and finally the compiled defaults. Comparisons use
378    /// `from_record` alone.
379    ///
380    /// # Errors
381    /// Refuses unresolved identity, an invalid release state, or a style
382    /// an existing target has not answered.
383    pub fn resolve(
384        target: &Utf8Path,
385        flags: &Inputs<'_>,
386        config: Option<&crate::config::Config>,
387        record: Option<&manifest::Manifest>,
388        purpose: Purpose,
389    ) -> Result<Self, RkError> {
390        resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
391    }
392
393    /// What the project is.
394    #[must_use]
395    pub const fn profile(&self) -> &ProfileSnapshot {
396        &self.profile
397    }
398
399    /// The Git workflow parameters.
400    #[must_use]
401    pub const fn git(&self) -> &GitWorkflow {
402        &self.git
403    }
404
405    /// The capability requests.
406    #[must_use]
407    pub const fn capabilities(&self) -> &CapabilityRequests {
408        &self.capabilities
409    }
410
411    /// The technologies present, sorted.
412    #[must_use]
413    pub fn technologies(&self) -> &[String] {
414        &self.profile.technologies
415    }
416
417    /// The forge, where the project has one.
418    #[must_use]
419    pub fn forge(&self) -> Option<&str> {
420        self.profile.forge.as_deref()
421    }
422
423    /// The release mode.
424    #[must_use]
425    pub const fn release_mode(&self) -> ReleaseMode {
426        self.profile.release.mode
427    }
428
429    /// The release driver, for an automatic release.
430    #[must_use]
431    pub fn driver(&self) -> Option<&str> {
432        self.profile.release.driver.as_deref()
433    }
434
435    /// The release style, for an automatic release that answered it.
436    #[must_use]
437    pub const fn style(&self) -> Option<Style> {
438        self.profile.release.style
439    }
440
441    /// Whether this landing requested the Nix capability.
442    #[must_use]
443    pub const fn nix_packaging(&self) -> bool {
444        self.capabilities.nix_packaging
445    }
446
447    /// Whether this landing requested the reporting policy.
448    #[must_use]
449    pub const fn reporting_policy(&self) -> bool {
450        self.capabilities.reporting_policy
451    }
452
453    /// Whether this landing requested the Scorecard capability.
454    #[must_use]
455    pub const fn scorecard(&self) -> bool {
456        self.capabilities.scorecard
457    }
458
459    /// The code scanning provider this landing requested, if any.
460    #[must_use]
461    pub const fn code_scanning(&self) -> Option<Provider> {
462        self.capabilities.code_scanning
463    }
464
465    /// The project path used by parameter-bearing files, empty where the
466    /// target has no forge repository.
467    #[must_use]
468    pub fn repo(&self) -> &str {
469        &self.repo
470    }
471
472    /// Where a topic branch opens.
473    #[must_use]
474    pub const fn checkout_mode(&self) -> CheckoutMode {
475        self.git.checkout_mode
476    }
477
478    /// Which authority moves an implementation onto the trunk.
479    #[must_use]
480    pub const fn integration(&self) -> Integration {
481        self.git.integration
482    }
483
484    /// The one permanent branch this landing writes into its artifacts.
485    #[must_use]
486    pub fn trunk(&self) -> &str {
487        &self.git.trunk
488    }
489
490    /// The release-line prefix this landing writes into its artifacts,
491    /// the compiled default where the release intent carries none.
492    #[must_use]
493    pub fn line_prefix(&self) -> &str {
494        self.profile
495            .release
496            .line_prefix
497            .as_deref()
498            .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
499    }
500
501    /// The contact the landed policy names, empty for the forge's own
502    /// authored wording.
503    #[must_use]
504    pub fn security_contact(&self) -> &str {
505        &self.security_contact
506    }
507
508    /// The acknowledgment window the landed policy promises.
509    #[must_use]
510    pub fn security_response(&self) -> &str {
511        &self.security_response
512    }
513
514    /// The check the release gate believes, empty where this target
515    /// renders no gate that reads one.
516    #[must_use]
517    pub fn required_check(&self) -> &str {
518        &self.required_check
519    }
520
521    /// The workflow whose completion wakes the release gate, empty where
522    /// this target renders no gate that waits on one.
523    #[must_use]
524    pub fn required_workflow(&self) -> &str {
525        &self.required_workflow
526    }
527
528    /// The canonical identity and Git workflow flags, as `rk init` and
529    /// `rk adopt` take them: every resolved answer stated, so a follow-up
530    /// command a preview prints applies the decision that was previewed.
531    #[must_use]
532    pub fn canonical_flags(&self) -> String {
533        let mut out = String::new();
534        for technology in &self.profile.technologies {
535            out.push_str(" --technology ");
536            out.push_str(technology);
537        }
538        if let Some(forge) = &self.profile.forge {
539            out.push_str(" --forge ");
540            out.push_str(forge);
541        }
542        // A preview stands in for an unresolved repository with the
543        // placeholder, and a replayed apply takes the operator's own path
544        // rather than that stand-in, so the flag stays out of the command.
545        if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
546            out.push_str(" --repo ");
547            out.push_str(&self.repo);
548        }
549        out.push_str(" --release-mode ");
550        out.push_str(self.profile.release.mode.as_str());
551        if let Some(driver) = &self.profile.release.driver {
552            out.push_str(" --release-driver ");
553            out.push_str(driver);
554        }
555        if let Some(style) = self.profile.release.style {
556            out.push_str(" --release-style ");
557            out.push_str(style.as_str());
558        }
559        out.push_str(" --trunk ");
560        out.push_str(&self.git.trunk);
561        out.push_str(" --checkout-mode ");
562        out.push_str(self.git.checkout_mode.as_str());
563        out.push_str(" --integration ");
564        out.push_str(self.git.integration.as_str());
565        // An unanswered gate has no flag form: the empty answer is the
566        // compiled default, and a replayed command that stated it would
567        // pass a name the resolution never produced.
568        if !self.required_check.is_empty() {
569            out.push_str(" --required-check ");
570            push_shell_word(&mut out, &self.required_check);
571        }
572        if !self.required_workflow.is_empty() {
573            out.push_str(" --required-workflow ");
574            push_shell_word(&mut out, &self.required_workflow);
575        }
576        out
577    }
578
579    /// Every opt-in capability's flag, as `rk init` and `rk adopt` take it.
580    ///
581    /// The resolved answers, not the flags the caller typed: a follow-up
582    /// command a preview prints must apply the decision that was previewed,
583    /// and the preview's decision is what resolution produced.
584    #[must_use]
585    pub fn capability_flags(&self) -> String {
586        let mut out = String::new();
587        if self.capabilities.nix_packaging {
588            out.push_str(" --nix-packaging");
589        }
590        if self.capabilities.reporting_policy {
591            out.push_str(" --reporting-policy");
592        }
593        if self.capabilities.scorecard {
594            out.push_str(" --scorecard");
595        }
596        // The provider flag takes a value, so `off` is a statable answer and
597        // is stated: a committed `capabilities.code_scanning` would
598        // otherwise re-enable on replay exactly what this preview turned
599        // off. The boolean flags above have no off form, so absence is
600        // their only honest rendering and no committed value can
601        // contradict it.
602        out.push_str(" --code-scanning ");
603        out.push_str(
604            self.capabilities
605                .code_scanning
606                .map_or("off", Provider::as_str),
607        );
608        out
609    }
610
611    /// The same answers as `rk upgrade` takes them, every one stated.
612    ///
613    /// An upgrade can turn a capability off as well as on, so absence is no
614    /// answer there and each value is rendered explicitly. That is what makes
615    /// a printed follow-up command reproduce the previewed decision rather
616    /// than re-resolve the configured one.
617    #[must_use]
618    pub fn capability_toggles(&self) -> String {
619        let word = |on: bool| if on { "on" } else { "off" };
620        format!(
621            " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {}",
622            word(self.capabilities.nix_packaging),
623            word(self.capabilities.reporting_policy),
624            word(self.capabilities.scorecard),
625            self.capabilities
626                .code_scanning
627                .map_or("off", Provider::as_str)
628        )
629    }
630}
631
632#[cfg(test)]
633impl Params {
634    /// A parameter set for tests alone: an automatic rust release on
635    /// GitHub. Production code reaches `Params` through `from_record` and
636    /// `resolve` and through nothing else, and this constructor is
637    /// compiled out of the shipped binary.
638    pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
639        Self {
640            profile: ProfileSnapshot {
641                technologies: vec!["rust".to_owned()],
642                forge: Some("github".to_owned()),
643                release: ReleaseIntent {
644                    mode: ReleaseMode::Automatic,
645                    driver: Some("rust".to_owned()),
646                    style,
647                    line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
648                },
649            },
650            git: GitWorkflow {
651                trunk: crate::config::TRUNK_DEFAULT.to_owned(),
652                checkout_mode: CheckoutMode::LinkedWorktree,
653                integration: Integration::Local,
654            },
655            capabilities: CapabilityRequests {
656                nix_packaging: false,
657                reporting_policy: true,
658                scorecard: false,
659                code_scanning: None,
660            },
661            repo: repo.to_owned(),
662            security_contact: String::new(),
663            security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
664            required_check: "gate".to_owned(),
665            required_workflow: "ci".to_owned(),
666        }
667    }
668
669    /// The same set with the two security parameters answered.
670    pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
671        Self {
672            security_contact: contact.to_owned(),
673            security_response: response.to_owned(),
674            ..Self::for_test("acme/widget", Some(Style::Trunk))
675        }
676    }
677
678    /// A release-less set for tests: the technologies and the forge as
679    /// given, no driver, no style.
680    pub(crate) fn for_test_release_less(
681        technologies: &[&str],
682        forge: Option<&str>,
683        mode: ReleaseMode,
684    ) -> Self {
685        let mut params = Self::for_test("acme/widget", None);
686        params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
687        params.profile.forge = forge.map(str::to_owned);
688        params.profile.release = ReleaseIntent {
689            mode,
690            driver: None,
691            style: None,
692            line_prefix: None,
693        };
694        params.capabilities.reporting_policy = false;
695        if forge.is_none() {
696            params.repo = String::new();
697        }
698        params
699    }
700
701    /// The same set with the forge and the driver changed.
702    pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
703        self.profile.technologies = vec![driver.to_owned()];
704        self.profile.release.driver = Some(driver.to_owned());
705        self.profile.forge = Some(forge.to_owned());
706    }
707
708    /// The same set with the checkout mode answered.
709    pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
710        self.git.checkout_mode = mode;
711    }
712
713    /// The same set with the integration mode answered.
714    pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
715        self.git.integration = mode;
716    }
717
718    /// The same set with the Nix opt-in answered.
719    pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
720        self.capabilities.nix_packaging = nix;
721    }
722
723    /// The same set with the Scorecard opt-in answered.
724    pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
725        self.capabilities.scorecard = scorecard;
726    }
727
728    /// The same set with the code scanning provider answered.
729    pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
730        self.capabilities.code_scanning = provider;
731    }
732}
733
734/// The refusal for an invalid release state, naming the key and its
735/// valid shape.
736fn invalid_release(message: impl std::fmt::Display) -> RkError {
737    RkError::Usage(format!(
738        "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
739    ))
740}
741
742/// The canonical release gate answers, for the one shape that renders a
743/// gate reading them, or `None` for every other shape.
744///
745/// One shape consumes them: GitHub, an automatic release, the trunk
746/// style, and local integration. There the rendered release workflow
747/// wakes on a workflow completing and judges a named check, so a landing
748/// that left either empty would render a gate no event can ever satisfy.
749/// The names are this convention's own: `runbooks/setup.md` writes the
750/// gate job as `gate`, and `ci` is the workflow that carries it.
751///
752/// They are a compiled default and nothing more. A flag, the committed
753/// configuration, and a compatible record each answer ahead of them, the
754/// resolved answer is written back and recorded, and `rk setup check`
755/// proves it against the target's own workflow files. A project whose
756/// names differ states them and the default never applies.
757///
758/// Every other shape resolves to empty, because no rendered reader
759/// consumes the answer there. Under forge integration the trunk ruleset
760/// holds the release request and `setup.required_check` names the context
761/// that ruleset requires, which is the project's own answer and not this
762/// convention's, so `protect-trunk` keeps asking for it.
763#[must_use]
764pub fn gate_defaults(
765    profile: &ProfileSnapshot,
766    git: &GitWorkflow,
767) -> Option<(&'static str, &'static str)> {
768    let consuming = profile.forge.as_deref() == Some("github")
769        && profile.release.mode == ReleaseMode::Automatic
770        && profile.release.style == Some(Style::Trunk)
771        && git.integration == Integration::Local;
772    consuming.then_some(("gate", "ci"))
773}
774
775/// One gate answer read back from a record, falling to the compiled
776/// default where the record predates the field.
777fn gate_answer(
778    record: &manifest::Manifest,
779    pick: impl Fn((&'static str, &'static str)) -> &'static str,
780    held: impl Fn(&manifest::Parameters) -> &String,
781) -> String {
782    let recorded = held(&record.parameters);
783    if recorded.is_empty() {
784        gate_defaults(&record.profile, &record.git)
785            .map_or_else(String::new, |pair| pick(pair).to_owned())
786    } else {
787        recorded.clone()
788    }
789}
790
791/// One field's answer and where it came from.
792fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
793    chain
794        .into_iter()
795        .find_map(|(value, source)| value.map(|value| (value, source)))
796}
797
798/// Resolve every domain value by one precedence, keeping the source of
799/// each.
800///
801/// # Errors
802/// Refuses unresolved identity, an invalid release state, a duplicate or
803/// malformed category name, and a style an existing target has not
804/// answered.
805#[allow(
806    clippy::too_many_lines,
807    reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
808)]
809pub fn resolve(
810    target: &Utf8Path,
811    flags: &Inputs<'_>,
812    config: Option<&crate::config::Config>,
813    record: Option<&manifest::Manifest>,
814    purpose: Purpose,
815) -> Result<Resolved, RkError> {
816    let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
817    let observed = crate::detect::observe(target.as_std_path());
818    let known_drivers = catalog::known_drivers();
819
820    // Technologies: a supplied list replaces the declared one whole.
821    let (technologies, source) = answered([
822        (
823            (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
824            Source::Flag,
825        ),
826        (
827            config.and_then(|c| c.profile.technologies.clone()),
828            Source::Config,
829        ),
830        (
831            record.map(|r| r.profile.technologies.clone()),
832            Source::Record,
833        ),
834        (
835            Some(
836                observed
837                    .technologies
838                    .iter()
839                    .map(|t| (*t).to_owned())
840                    .collect(),
841            ),
842            Source::Observation,
843        ),
844        (None, Source::Default),
845    ])
846    .unwrap_or_else(|| (Vec::new(), Source::Default));
847    let technologies =
848        canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
849    sources.insert("profile.technologies", source);
850
851    // The forge: an explicit empty configuration value states no forge.
852    let forge_flag = flags
853        .forge
854        .map(|name| canonical_category(name).map_err(RkError::Usage))
855        .transpose()?;
856    let (forge, source) = answered([
857        (forge_flag.map(Some), Source::Flag),
858        (
859            config
860                .and_then(|c| c.profile.forge.clone())
861                .map(|value| if value.is_empty() { None } else { Some(value) }),
862            Source::Config,
863        ),
864        (record.map(|r| r.profile.forge.clone()), Source::Record),
865        (
866            observed.forge.map(|forge| Some(forge.as_str().to_owned())),
867            Source::Observation,
868        ),
869        (Some(None), Source::Default),
870    ])
871    .unwrap_or((None, Source::Default));
872    let forge = forge
873        .map(|name| canonical_category(&name).map_err(RkError::Usage))
874        .transpose()?;
875    sources.insert("profile.forge", source);
876
877    // The repository identity, needed only where a forge is present.
878    let (repo, source) = answered([
879        (flags.repo.map(str::to_owned), Source::Flag),
880        (
881            config
882                .map(|c| c.project.repo.clone())
883                .filter(|value| !value.is_empty()),
884            Source::Config,
885        ),
886        (
887            record
888                .map(|r| r.parameters.repo.clone())
889                .filter(|value| !value.is_empty()),
890            Source::Record,
891        ),
892        (observed.repo.clone(), Source::Observation),
893        (None, Source::Default),
894    ])
895    .map_or((None, Source::Default), |(value, source)| {
896        (Some(value), source)
897    });
898    sources.insert("project.repo", source);
899
900    // The release mode: the observation proposes where nothing above
901    // answers.
902    let release_bearing: Vec<String> = technologies
903        .iter()
904        .filter(|name| known_drivers.contains(name))
905        .cloned()
906        .collect();
907    let proposal = match release_bearing.as_slice() {
908        [] => Proposal::None,
909        [one] => Proposal::Automatic {
910            driver: one.clone(),
911        },
912        many => Proposal::Ambiguous {
913            drivers: many.to_vec(),
914        },
915    };
916    // The proposal reads the version files alone. A missing forge is not
917    // an answer about the release intent: it is a separate refusal the
918    // automatic branch below raises, naming the remote it did not find and
919    // the two ways out. Folding it in here would silently land a
920    // release-less target for a crate whose author simply has no remote
921    // yet, and the record would then claim a release intent nobody stated.
922    let proposed_mode = match &proposal {
923        Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
924        Proposal::None => ReleaseMode::None,
925    };
926    let (mode, source) = answered([
927        (flags.release_mode, Source::Flag),
928        (config.and_then(|c| c.profile.release.mode), Source::Config),
929        (record.map(|r| r.profile.release.mode), Source::Record),
930        (Some(proposed_mode), Source::Observation),
931        (None, Source::Default),
932    ])
933    .unwrap_or((ReleaseMode::None, Source::Default));
934    let mode_source = source;
935    sources.insert("profile.release.mode", mode_source);
936    let mode_answered_above = mode_source != Source::Observation;
937    let proposal = (!mode_answered_above).then_some(proposal);
938
939    // The driver, style, and line prefix belong to an automatic release
940    // alone, and a value from a flag or the configuration under another
941    // mode is a malformed intent rather than an ignored one.
942    let driver_flag = flags
943        .release_driver
944        .map(|name| canonical_category(name).map_err(RkError::Usage))
945        .transpose()?;
946    let (driver, driver_source) = answered([
947        (driver_flag, Source::Flag),
948        (
949            config.and_then(|c| c.profile.release.driver.clone()),
950            Source::Config,
951        ),
952        (
953            record.and_then(|r| r.profile.release.driver.clone()),
954            Source::Record,
955        ),
956        (
957            match &proposal {
958                Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
959                    Some(driver.clone())
960                }
961                _ => None,
962            },
963            Source::Observation,
964        ),
965        (None, Source::Default),
966    ])
967    .map_or((None, Source::Default), |(value, source)| {
968        (Some(value), source)
969    });
970    let (style, style_source) = answered([
971        (flags.style, Source::Flag),
972        (config.and_then(|c| c.profile.release.style), Source::Config),
973        (record.and_then(|r| r.profile.release.style), Source::Record),
974        (None, Source::Observation),
975        (
976            (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
977                .then_some(Style::Trunk),
978            Source::Default,
979        ),
980    ])
981    .map_or((None, Source::Default), |(value, source)| {
982        (Some(value), source)
983    });
984    let (line_prefix, prefix_source) = answered([
985        (None, Source::Flag),
986        (
987            config.and_then(|c| c.profile.release.line_prefix.clone()),
988            Source::Config,
989        ),
990        (
991            record.and_then(|r| r.profile.release.line_prefix.clone()),
992            Source::Record,
993        ),
994        (None, Source::Observation),
995        (
996            (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
997            Source::Default,
998        ),
999    ])
1000    .map_or((None, Source::Default), |(value, source)| {
1001        (Some(value), source)
1002    });
1003
1004    // A reporting purpose never refuses what it can state: `rk profile`
1005    // and every preview report an intent the target cannot yet take, and
1006    // the capability catalog says why. A purpose that writes refuses,
1007    // because a record must not claim a release nothing can land.
1008    let reporting = purpose == Purpose::Preview;
1009    let release = match mode {
1010        ReleaseMode::Automatic => {
1011            if let Some(Proposal::Ambiguous { drivers }) = &proposal
1012                && driver.is_none()
1013                && !reporting
1014            {
1015                return Err(RkError::Usage(format!(
1016                    "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1017                    drivers.join(" and "),
1018                    crate::config::CONFIG_PATH
1019                )));
1020            }
1021            if forge.is_none() && !reporting {
1022                let message = observed.host.map_or_else(
1023                    || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1024                    |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1025                );
1026                return Err(RkError::refusal(
1027                    Diagnostic::new(Reason::ForgeUndetected, message)
1028                        .expected("a github.com or gitlab remote, or --forge")
1029                        .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1030                ));
1031            }
1032            if driver.is_none() && !reporting {
1033                return Err(invalid_release(format!(
1034                    "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1035                    known_drivers.join("|")
1036                )));
1037            }
1038            if let Some(driver) = &driver
1039                && !technologies.contains(driver)
1040                && !reporting
1041            {
1042                return Err(invalid_release(format!(
1043                    "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1044                    if technologies.is_empty() {
1045                        "empty".to_owned()
1046                    } else {
1047                        technologies.join(", ")
1048                    }
1049                )));
1050            }
1051            let style = match (style, purpose) {
1052                (None, Purpose::Upgrade | Purpose::Adopt) => {
1053                    return Err(RkError::Usage(
1054                        "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1055                    ));
1056                }
1057                (None, _) => Style::Trunk,
1058                (Some(style), _) => style,
1059            };
1060            sources.insert("profile.release.driver", driver_source);
1061            sources.insert("profile.release.style", style_source);
1062            sources.insert("profile.release.line_prefix", prefix_source);
1063            ReleaseIntent {
1064                mode,
1065                driver,
1066                style: Some(style),
1067                line_prefix: Some(
1068                    line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1069                ),
1070            }
1071        }
1072        ReleaseMode::External | ReleaseMode::None => {
1073            // A stated value under a mode that has no room for it is a
1074            // malformed intent. A value the mode outranks is not: that is
1075            // ordinary precedence, and `--release-mode none` over a
1076            // configured automatic release is the one command that retires
1077            // it. So the refusal fires only where the subordinate value
1078            // speaks at or above the tier that chose the mode.
1079            for (key, present, value_source) in [
1080                ("profile.release.driver", driver.is_some(), driver_source),
1081                ("profile.release.style", style.is_some(), style_source),
1082                (
1083                    "profile.release.line_prefix",
1084                    line_prefix.is_some(),
1085                    prefix_source,
1086                ),
1087            ] {
1088                if present
1089                    && matches!(value_source, Source::Flag | Source::Config)
1090                    && value_source.rank() <= mode_source.rank()
1091                {
1092                    return Err(invalid_release(format!(
1093                        "{key} is set while profile.release.mode is {}",
1094                        mode.as_str()
1095                    )));
1096                }
1097            }
1098            ReleaseIntent {
1099                mode,
1100                driver: None,
1101                style: None,
1102                line_prefix: None,
1103            }
1104        }
1105    };
1106
1107    // Every capability this binary ships for a forge renders the project
1108    // path, so the identity is required exactly where the forge has an
1109    // adapter. An unknown forge selects no such capability and needs none.
1110    let adapter_known = forge
1111        .as_deref()
1112        .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1113    let repo = match (forge.is_some(), adapter_known, repo) {
1114        // No forge at all: the identity has nowhere to point, so a lower
1115        // tier's remote or record must not survive into `[project]`.
1116        (false, _, _) => String::new(),
1117        (true, false, repo) => repo.unwrap_or_default(),
1118        (true, true, Some(repo)) => repo,
1119        (true, true, None) if purpose == Purpose::Preview => {
1120            crate::projection::REPO_PLACEHOLDER.to_owned()
1121        }
1122        (true, true, None) => return Err(crate::landing::repo_unresolved()),
1123    };
1124
1125    // The Git workflow.
1126    let (trunk, source) = answered([
1127        (flags.trunk.map(str::to_owned), Source::Flag),
1128        (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1129        (record.map(|r| r.git.trunk.clone()), Source::Record),
1130        (None, Source::Observation),
1131        (
1132            Some(crate::config::TRUNK_DEFAULT.to_owned()),
1133            Source::Default,
1134        ),
1135    ])
1136    .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1137    sources.insert("git.trunk", source);
1138    let (checkout_mode, source) = answered([
1139        (flags.checkout_mode, Source::Flag),
1140        (config.and_then(|c| c.git.checkout_mode), Source::Config),
1141        (record.map(|r| r.git.checkout_mode), Source::Record),
1142        (None, Source::Observation),
1143        (
1144            Some(if purpose == Purpose::Adopt {
1145                CheckoutMode::MainWorktree
1146            } else {
1147                CheckoutMode::LinkedWorktree
1148            }),
1149            Source::Default,
1150        ),
1151    ])
1152    .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1153    sources.insert("git.checkout_mode", source);
1154    // The compiled default is `local` and the record's absence answers
1155    // `forge`. The two differ deliberately: a fresh landing takes the
1156    // cheaper authority, and a target that landed before this axis
1157    // existed keeps the one whose blocks and protections it carries.
1158    let (integration, source) = answered([
1159        (flags.integration, Source::Flag),
1160        (config.and_then(|c| c.git.integration), Source::Config),
1161        (record.map(|r| r.git.integration), Source::Record),
1162        (None, Source::Observation),
1163        // An adoption defaults to `forge`, the way it defaults to the
1164        // main worktree: it is describing a target that already exists,
1165        // and every target that landed before this axis carries the
1166        // forge blocks. A fresh landing takes `local`.
1167        (
1168            Some(if purpose == Purpose::Adopt {
1169                Integration::Forge
1170            } else {
1171                Integration::Local
1172            }),
1173            Source::Default,
1174        ),
1175    ])
1176    .unwrap_or((Integration::Local, Source::Default));
1177    sources.insert("git.integration", source);
1178
1179    // The capability requests.
1180    let (nix_packaging, source) = answered([
1181        (flags.nix, Source::Flag),
1182        (
1183            config.and_then(|c| c.capabilities.nix_packaging),
1184            Source::Config,
1185        ),
1186        (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1187        (None, Source::Observation),
1188        (Some(false), Source::Default),
1189    ])
1190    .unwrap_or((false, Source::Default));
1191    sources.insert("capabilities.nix_packaging", source);
1192    let (reporting_policy, source) = answered([
1193        (flags.reporting_policy, Source::Flag),
1194        (
1195            config.and_then(|c| c.capabilities.reporting_policy),
1196            Source::Config,
1197        ),
1198        (
1199            record.map(|r| r.capabilities.reporting_policy),
1200            Source::Record,
1201        ),
1202        (None, Source::Observation),
1203        // A project that automates its release carries the policy by
1204        // default; a release-less profile asks for it explicitly.
1205        (
1206            Some(release.mode == ReleaseMode::Automatic),
1207            Source::Default,
1208        ),
1209    ])
1210    .unwrap_or((false, Source::Default));
1211    sources.insert("capabilities.reporting_policy", source);
1212    let (scorecard, source) = answered([
1213        (flags.scorecard, Source::Flag),
1214        (
1215            config.and_then(|c| c.capabilities.scorecard),
1216            Source::Config,
1217        ),
1218        (record.map(|r| r.capabilities.scorecard), Source::Record),
1219        (None, Source::Observation),
1220        (Some(false), Source::Default),
1221    ])
1222    .unwrap_or((false, Source::Default));
1223    sources.insert("capabilities.scorecard", source);
1224    let configured_scanning = config
1225        .and_then(|c| c.capabilities.code_scanning.as_deref())
1226        .map(Provider::parse)
1227        .transpose()?;
1228    let (code_scanning, source) = answered([
1229        (flags.code_scanning, Source::Flag),
1230        (configured_scanning, Source::Config),
1231        (record.map(|r| r.capabilities.code_scanning), Source::Record),
1232        (None, Source::Observation),
1233        (Some(None), Source::Default),
1234    ])
1235    .unwrap_or((None, Source::Default));
1236    sources.insert("capabilities.code_scanning", source);
1237    // A requested scanner this release cannot land at these dimensions is
1238    // an unavailable optional capability, not a malformed request. The
1239    // catalog reports it and the landing omits it, which is what
1240    // `project-profile:an-operation-refuses-only-what-it-requires` says
1241    // must happen: only the selected release automation blocks an apply.
1242
1243    // The security policy's two answers.
1244    let (security_contact, source) = answered([
1245        (None, Source::Flag),
1246        (
1247            config.and_then(|c| c.security.contact.clone()),
1248            Source::Config,
1249        ),
1250        (
1251            record.map(|r| r.parameters.security_contact.clone()),
1252            Source::Record,
1253        ),
1254        (None, Source::Observation),
1255        (Some(String::new()), Source::Default),
1256    ])
1257    .unwrap_or((String::new(), Source::Default));
1258    let security_contact =
1259        crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1260    sources.insert("security.contact", source);
1261    let (security_response, source) = answered([
1262        (None, Source::Flag),
1263        (
1264            config.and_then(|c| c.security.response.clone()),
1265            Source::Config,
1266        ),
1267        (
1268            record.map(|r| r.parameters.security_response.clone()),
1269            Source::Record,
1270        ),
1271        (None, Source::Observation),
1272        (
1273            Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1274            Source::Default,
1275        ),
1276    ])
1277    .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1278    let security_response =
1279        crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1280    sources.insert("security.response", source);
1281
1282    // The release gate's two answers. Neither has an observation: nothing
1283    // in the target proposes a name. The compiled default is the
1284    // convention's own pair, and it applies to the one shape that renders
1285    // a gate reading them.
1286    let gate_default = gate_defaults(
1287        &ProfileSnapshot {
1288            technologies: technologies.clone(),
1289            forge: forge.clone(),
1290            release: release.clone(),
1291        },
1292        &GitWorkflow {
1293            trunk: trunk.clone(),
1294            checkout_mode,
1295            integration,
1296        },
1297    );
1298    let (required_check, source) = answered([
1299        (flags.required_check.map(str::to_owned), Source::Flag),
1300        (
1301            config
1302                .map(|c| c.setup.required_check.clone())
1303                .filter(|name| !name.is_empty()),
1304            Source::Config,
1305        ),
1306        (
1307            record
1308                .map(|r| r.parameters.required_check.clone())
1309                .filter(|name| !name.is_empty()),
1310            Source::Record,
1311        ),
1312        (None, Source::Observation),
1313        (
1314            Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1315            Source::Default,
1316        ),
1317    ])
1318    .unwrap_or((String::new(), Source::Default));
1319    sources.insert("setup.required_check", source);
1320    let (required_workflow, source) = answered([
1321        (flags.required_workflow.map(str::to_owned), Source::Flag),
1322        (
1323            config
1324                .map(|c| c.setup.required_workflow.clone())
1325                .filter(|name| !name.is_empty()),
1326            Source::Config,
1327        ),
1328        (
1329            record
1330                .map(|r| r.parameters.required_workflow.clone())
1331                .filter(|name| !name.is_empty()),
1332            Source::Record,
1333        ),
1334        (None, Source::Observation),
1335        (
1336            Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1337            Source::Default,
1338        ),
1339    ])
1340    .unwrap_or((String::new(), Source::Default));
1341    sources.insert("setup.required_workflow", source);
1342    // GitLab requires the whole pipeline through one project setting and
1343    // names no individual check, so neither answer has a reader there.
1344    // Refusing the flag says that; discarding it silently would let an
1345    // operator believe a gate was configured.
1346    if forge.as_deref() == Some("gitlab") {
1347        for (flag, supplied) in [
1348            ("--required-check", flags.required_check),
1349            ("--required-workflow", flags.required_workflow),
1350        ] {
1351            if supplied.is_some() {
1352                return Err(RkError::Usage(format!(
1353                    "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1354                )));
1355            }
1356        }
1357    }
1358
1359    let mut unknown: Vec<String> = technologies
1360        .iter()
1361        .filter(|name| !known_drivers.contains(name))
1362        .map(|name| format!("technology {name}"))
1363        .collect();
1364    if let Some(name) = &forge
1365        && crate::detect::Forge::parse(name).is_none()
1366    {
1367        unknown.push(format!("forge {name}"));
1368    }
1369
1370    Ok(Resolved {
1371        params: Params {
1372            profile: ProfileSnapshot {
1373                technologies,
1374                forge,
1375                release,
1376            },
1377            git: GitWorkflow {
1378                trunk,
1379                checkout_mode,
1380                integration,
1381            },
1382            capabilities: CapabilityRequests {
1383                nix_packaging,
1384                reporting_policy,
1385                scorecard,
1386                code_scanning,
1387            },
1388            repo,
1389            security_contact,
1390            security_response,
1391            required_check,
1392            required_workflow,
1393        },
1394        sources,
1395        unknown,
1396        proposal,
1397    })
1398}