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