1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ReleaseMode {
35 Automatic,
38 External,
41 None,
43}
44
45impl ReleaseMode {
46 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ReleaseIntent {
79 pub mode: ReleaseMode,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub driver: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub style: Option<Style>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub line_prefix: Option<String>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct ProfileSnapshot {
97 pub technologies: Vec<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub forge: Option<String>,
102 pub release: ReleaseIntent,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct GitWorkflow {
109 pub trunk: String,
111 pub checkout_mode: CheckoutMode,
113 #[serde(default = "crate::landing::manifest::integration_forge")]
116 pub integration: Integration,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct CapabilityRequests {
122 #[serde(default)]
124 pub nix_packaging: bool,
125 #[serde(default)]
127 pub reporting_policy: bool,
128 #[serde(default)]
130 pub scorecard: bool,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub code_scanning: Option<Provider>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
138#[serde(rename_all = "lowercase")]
139pub enum Source {
140 Flag,
142 Config,
144 Record,
146 Observation,
148 Default,
150}
151
152impl Source {
153 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "kebab-case", tag = "state")]
186pub enum Proposal {
187 None,
189 Automatic {
191 driver: String,
193 },
194 Ambiguous {
197 drivers: Vec<String>,
199 },
200}
201
202#[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#[derive(Default)]
220pub struct Inputs<'a> {
221 pub technologies: &'a [String],
224 pub forge: Option<&'a str>,
226 pub repo: Option<&'a str>,
228 pub release_mode: Option<ReleaseMode>,
230 pub release_driver: Option<&'a str>,
232 pub style: Option<Style>,
234 pub trunk: Option<&'a str>,
236 pub checkout_mode: Option<CheckoutMode>,
238 pub integration: Option<Integration>,
240 pub required_check: Option<&'a str>,
242 pub required_workflow: Option<&'a str>,
245 pub nix: Option<bool>,
247 pub reporting_policy: Option<bool>,
249 pub scorecard: Option<bool>,
251 pub code_scanning: Option<Option<Provider>>,
254}
255
256#[derive(Clone, Copy, PartialEq, Eq)]
258pub enum Purpose {
259 Init,
261 Preview,
264 Upgrade,
266 Adopt,
268}
269
270#[derive(Debug, Clone)]
272pub struct Resolved {
273 pub params: Params,
275 pub sources: BTreeMap<&'static str, Source>,
277 pub unknown: Vec<String>,
279 pub proposal: Option<Proposal>,
282}
283
284pub 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
311pub 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 #[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 required_check: gate_answer(
344 record,
345 |(check, _)| check,
346 |parameters| ¶meters.required_check,
347 ),
348 required_workflow: gate_answer(
349 record,
350 |(_, workflow)| workflow,
351 |parameters| ¶meters.required_workflow,
352 ),
353 }
354 }
355
356 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 #[must_use]
375 pub const fn profile(&self) -> &ProfileSnapshot {
376 &self.profile
377 }
378
379 #[must_use]
381 pub const fn git(&self) -> &GitWorkflow {
382 &self.git
383 }
384
385 #[must_use]
387 pub const fn capabilities(&self) -> &CapabilityRequests {
388 &self.capabilities
389 }
390
391 #[must_use]
393 pub fn technologies(&self) -> &[String] {
394 &self.profile.technologies
395 }
396
397 #[must_use]
399 pub fn forge(&self) -> Option<&str> {
400 self.profile.forge.as_deref()
401 }
402
403 #[must_use]
405 pub const fn release_mode(&self) -> ReleaseMode {
406 self.profile.release.mode
407 }
408
409 #[must_use]
411 pub fn driver(&self) -> Option<&str> {
412 self.profile.release.driver.as_deref()
413 }
414
415 #[must_use]
417 pub const fn style(&self) -> Option<Style> {
418 self.profile.release.style
419 }
420
421 #[must_use]
423 pub const fn nix_packaging(&self) -> bool {
424 self.capabilities.nix_packaging
425 }
426
427 #[must_use]
429 pub const fn reporting_policy(&self) -> bool {
430 self.capabilities.reporting_policy
431 }
432
433 #[must_use]
435 pub const fn scorecard(&self) -> bool {
436 self.capabilities.scorecard
437 }
438
439 #[must_use]
441 pub const fn code_scanning(&self) -> Option<Provider> {
442 self.capabilities.code_scanning
443 }
444
445 #[must_use]
448 pub fn repo(&self) -> &str {
449 &self.repo
450 }
451
452 #[must_use]
454 pub const fn checkout_mode(&self) -> CheckoutMode {
455 self.git.checkout_mode
456 }
457
458 #[must_use]
460 pub const fn integration(&self) -> Integration {
461 self.git.integration
462 }
463
464 #[must_use]
466 pub fn trunk(&self) -> &str {
467 &self.git.trunk
468 }
469
470 #[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 #[must_use]
484 pub fn security_contact(&self) -> &str {
485 &self.security_contact
486 }
487
488 #[must_use]
490 pub fn security_response(&self) -> &str {
491 &self.security_response
492 }
493
494 #[must_use]
497 pub fn required_check(&self) -> &str {
498 &self.required_check
499 }
500
501 #[must_use]
504 pub fn required_workflow(&self) -> &str {
505 &self.required_workflow
506 }
507
508 #[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 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 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 #[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 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 #[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 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 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 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 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 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
690 self.git.checkout_mode = mode;
691 }
692
693 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
695 self.git.integration = mode;
696 }
697
698 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
700 self.capabilities.nix_packaging = nix;
701 }
702
703 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
705 self.capabilities.scorecard = scorecard;
706 }
707
708 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
710 self.capabilities.code_scanning = provider;
711 }
712}
713
714fn 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#[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
755fn 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
771fn 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#[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 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 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 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 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 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 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 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 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 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 (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 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 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 (
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 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 (
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 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 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 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}