1pub mod catalog;
21
22use std::collections::BTreeMap;
23
24use camino::Utf8Path;
25use serde::{Deserialize, Serialize};
26
27use crate::config::dev_environment::DevEnvironment;
28use crate::diagnostic::{Diagnostic, Reason};
29use crate::error::RkError;
30use crate::landing::manifest::{self, CheckoutMode, Integration, MergeForm, Provider, Style};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ReleaseMode {
36 Automatic,
39 External,
42 None,
44}
45
46impl ReleaseMode {
47 #[must_use]
49 pub const fn as_str(self) -> &'static str {
50 match self {
51 Self::Automatic => "automatic",
52 Self::External => "external",
53 Self::None => "none",
54 }
55 }
56
57 pub fn parse(raw: &str) -> Result<Self, RkError> {
63 match raw {
64 "automatic" => Ok(Self::Automatic),
65 "external" => Ok(Self::External),
66 "none" => Ok(Self::None),
67 other => Err(RkError::Usage(format!(
68 "unknown release mode '{other}'; the modes are: automatic, external, none"
69 ))),
70 }
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "kebab-case")]
81pub enum RegistryAnswer {
82 CratesIo,
84 None,
87}
88
89impl RegistryAnswer {
90 #[must_use]
92 pub const fn as_str(self) -> &'static str {
93 match self {
94 Self::CratesIo => "crates-io",
95 Self::None => "none",
96 }
97 }
98
99 pub const DRIVER: &'static str = "rust";
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields, default)]
112pub struct BuildAnswer {
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub command: Option<String>,
116 #[serde(skip_serializing_if = "Option::is_none")]
118 pub packages: Option<String>,
119}
120
121impl BuildAnswer {
122 pub const DRIVER: &'static str = "bash";
124 pub const COMMAND: &'static str = "make dist";
126 pub const PACKAGES: &'static str = "make";
128
129 #[must_use]
131 pub const fn is_empty(&self) -> bool {
132 self.command.is_none() && self.packages.is_none()
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct ReleaseIntent {
142 pub mode: ReleaseMode,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub driver: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub style: Option<Style>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub line_prefix: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub registry: Option<RegistryAnswer>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub build: Option<BuildAnswer>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct ProfileSnapshot {
170 pub technologies: Vec<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub forge: Option<String>,
175 pub release: ReleaseIntent,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct GitWorkflow {
182 pub trunk: String,
184 pub checkout_mode: CheckoutMode,
186 #[serde(default = "crate::landing::manifest::integration_forge")]
189 pub integration: Integration,
190 #[serde(default = "crate::landing::manifest::merge_form_squash")]
193 pub merge_form: MergeForm,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct CapabilityRequests {
199 #[serde(default)]
201 pub nix_packaging: bool,
202 #[serde(default)]
204 pub reporting_policy: bool,
205 #[serde(default)]
207 pub scorecard: bool,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub code_scanning: Option<Provider>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub workflow_guards: Option<bool>,
217}
218
219impl CapabilityRequests {
220 #[must_use]
224 pub fn workflow_guards(&self) -> bool {
225 self.workflow_guards.unwrap_or(true)
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
231#[serde(rename_all = "lowercase")]
232pub enum Source {
233 Flag,
235 Config,
237 Record,
239 Observation,
241 Default,
243}
244
245impl Source {
246 #[must_use]
252 pub const fn rank(self) -> u8 {
253 match self {
254 Self::Flag => 0,
255 Self::Config => 1,
256 Self::Record => 2,
257 Self::Observation => 3,
258 Self::Default => 4,
259 }
260 }
261
262 #[must_use]
264 pub const fn as_str(self) -> &'static str {
265 match self {
266 Self::Flag => "flag",
267 Self::Config => "config",
268 Self::Record => "record",
269 Self::Observation => "observation",
270 Self::Default => "default",
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
278#[serde(rename_all = "kebab-case", tag = "state")]
279pub enum Proposal {
280 None,
282 Automatic {
284 driver: String,
286 },
287 Ambiguous {
290 drivers: Vec<String>,
292 },
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct Params {
301 profile: ProfileSnapshot,
302 git: GitWorkflow,
303 capabilities: CapabilityRequests,
304 repo: String,
305 security_contact: String,
306 security_response: String,
307 security_scope: String,
308 required_check: String,
309 required_workflow: String,
310 dev_environment: Option<DevEnvironment>,
311}
312
313#[derive(Default)]
315pub struct Inputs<'a> {
316 pub technologies: &'a [String],
319 pub forge: Option<&'a str>,
321 pub repo: Option<&'a str>,
323 pub release_mode: Option<ReleaseMode>,
325 pub release_driver: Option<&'a str>,
327 pub style: Option<Style>,
329 pub trunk: Option<&'a str>,
331 pub checkout_mode: Option<CheckoutMode>,
333 pub integration: Option<Integration>,
335 pub merge_form: Option<MergeForm>,
337 pub required_check: Option<&'a str>,
339 pub required_workflow: Option<&'a str>,
342 pub build_command: Option<&'a str>,
344 pub build_packages: Option<&'a str>,
346 pub nix: Option<bool>,
348 pub reporting_policy: Option<bool>,
350 pub scorecard: Option<bool>,
352 pub code_scanning: Option<Option<Provider>>,
355 pub workflow_guards: Option<bool>,
357}
358
359#[derive(Clone, Copy, PartialEq, Eq)]
361pub enum Purpose {
362 Init,
364 Preview,
367 Upgrade,
369 Adopt,
371}
372
373#[derive(Debug, Clone)]
375pub struct Resolved {
376 pub params: Params,
378 pub sources: BTreeMap<&'static str, Source>,
380 pub unknown: Vec<String>,
382 pub proposal: Option<Proposal>,
385 pub bot_name: String,
389}
390
391pub fn canonical_category(raw: &str) -> Result<String, String> {
401 let lowered = raw.trim().to_ascii_lowercase();
402 let shaped = lowered
403 .chars()
404 .next()
405 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
406 && lowered
407 .chars()
408 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
409 if shaped {
410 Ok(lowered)
411 } else {
412 Err(format!(
413 "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
414 ))
415 }
416}
417
418pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
424 let mut out = Vec::with_capacity(raw.len());
425 for value in raw {
426 let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
427 if out.contains(&name) {
428 return Err(format!("{key} names {name} twice; each technology once"));
429 }
430 out.push(name);
431 }
432 out.sort();
433 Ok(out)
434}
435
436fn push_shell_word(out: &mut String, value: &str) {
439 let inert = !value.is_empty()
440 && value.bytes().all(|byte| {
441 byte.is_ascii_alphanumeric()
442 || matches!(
443 byte,
444 b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
445 )
446 });
447 if inert {
448 out.push_str(value);
449 return;
450 }
451 out.push('\'');
452 out.push_str(&value.replace('\'', "'\"'\"'"));
453 out.push('\'');
454}
455
456impl Params {
457 #[must_use]
460 pub fn from_record(record: &manifest::Manifest) -> Self {
461 Self {
462 profile: record.profile.clone(),
463 git: record.git.clone(),
464 capabilities: record.capabilities.clone(),
465 repo: record.parameters.repo.clone(),
466 security_contact: record.parameters.security_contact.clone(),
467 security_response: record.parameters.security_response.clone(),
468 security_scope: record.parameters.security_scope.clone(),
469 required_check: gate_answer(
472 record,
473 |(check, _)| check,
474 |parameters| ¶meters.required_check,
475 ),
476 required_workflow: gate_answer(
477 record,
478 |(_, workflow)| workflow,
479 |parameters| ¶meters.required_workflow,
480 ),
481 dev_environment: record.parameters.dev_environment.clone(),
482 }
483 }
484
485 pub fn resolve(
493 target: &Utf8Path,
494 flags: &Inputs<'_>,
495 config: Option<&crate::config::Config>,
496 record: Option<&manifest::Manifest>,
497 purpose: Purpose,
498 ) -> Result<Self, RkError> {
499 resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
500 }
501
502 #[must_use]
504 pub const fn profile(&self) -> &ProfileSnapshot {
505 &self.profile
506 }
507
508 #[must_use]
510 pub const fn git(&self) -> &GitWorkflow {
511 &self.git
512 }
513
514 #[must_use]
516 pub const fn capabilities(&self) -> &CapabilityRequests {
517 &self.capabilities
518 }
519
520 #[must_use]
522 pub fn technologies(&self) -> &[String] {
523 &self.profile.technologies
524 }
525
526 #[must_use]
528 pub fn forge(&self) -> Option<&str> {
529 self.profile.forge.as_deref()
530 }
531
532 #[must_use]
534 pub const fn release_mode(&self) -> ReleaseMode {
535 self.profile.release.mode
536 }
537
538 #[must_use]
540 pub fn driver(&self) -> Option<&str> {
541 self.profile.release.driver.as_deref()
542 }
543
544 #[must_use]
546 pub const fn style(&self) -> Option<Style> {
547 self.profile.release.style
548 }
549
550 #[must_use]
553 pub fn registry(&self) -> Option<RegistryAnswer> {
554 (self.release_mode() == ReleaseMode::Automatic
555 && self.driver() == Some(RegistryAnswer::DRIVER))
556 .then(|| {
557 self.profile
558 .release
559 .registry
560 .unwrap_or(RegistryAnswer::CratesIo)
561 })
562 }
563
564 #[must_use]
567 pub fn build_command(&self) -> &str {
568 self.profile
569 .release
570 .build
571 .as_ref()
572 .and_then(|build| build.command.as_deref())
573 .unwrap_or(BuildAnswer::COMMAND)
574 }
575
576 #[must_use]
579 pub fn build_packages(&self) -> &str {
580 self.profile
581 .release
582 .build
583 .as_ref()
584 .and_then(|build| build.packages.as_deref())
585 .unwrap_or(BuildAnswer::PACKAGES)
586 }
587
588 #[must_use]
590 pub const fn nix_packaging(&self) -> bool {
591 self.capabilities.nix_packaging
592 }
593
594 #[must_use]
596 pub const fn reporting_policy(&self) -> bool {
597 self.capabilities.reporting_policy
598 }
599
600 #[must_use]
602 pub const fn scorecard(&self) -> bool {
603 self.capabilities.scorecard
604 }
605
606 #[must_use]
608 pub const fn code_scanning(&self) -> Option<Provider> {
609 self.capabilities.code_scanning
610 }
611
612 #[must_use]
614 pub fn workflow_guards(&self) -> bool {
615 self.capabilities.workflow_guards()
616 }
617
618 #[must_use]
621 pub fn repo(&self) -> &str {
622 &self.repo
623 }
624
625 #[must_use]
627 pub const fn checkout_mode(&self) -> CheckoutMode {
628 self.git.checkout_mode
629 }
630
631 #[must_use]
633 pub const fn integration(&self) -> Integration {
634 self.git.integration
635 }
636
637 #[must_use]
639 pub const fn merge_form(&self) -> MergeForm {
640 self.git.merge_form
641 }
642
643 #[must_use]
645 pub fn trunk(&self) -> &str {
646 &self.git.trunk
647 }
648
649 #[must_use]
652 pub fn line_prefix(&self) -> &str {
653 self.profile
654 .release
655 .line_prefix
656 .as_deref()
657 .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
658 }
659
660 #[must_use]
663 pub fn security_contact(&self) -> &str {
664 &self.security_contact
665 }
666
667 #[must_use]
669 pub fn security_response(&self) -> &str {
670 &self.security_response
671 }
672
673 #[must_use]
676 pub fn security_scope(&self) -> &str {
677 &self.security_scope
678 }
679
680 #[must_use]
683 pub fn required_check(&self) -> &str {
684 &self.required_check
685 }
686
687 #[must_use]
690 pub const fn dev_environment(&self) -> Option<&DevEnvironment> {
691 self.dev_environment.as_ref()
692 }
693
694 #[must_use]
697 pub fn required_workflow(&self) -> &str {
698 &self.required_workflow
699 }
700
701 #[must_use]
705 pub fn canonical_flags(&self) -> String {
706 let mut out = String::new();
707 for technology in &self.profile.technologies {
708 out.push_str(" --technology ");
709 out.push_str(technology);
710 }
711 if let Some(forge) = &self.profile.forge {
712 out.push_str(" --forge ");
713 out.push_str(forge);
714 }
715 if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
719 out.push_str(" --repo ");
720 out.push_str(&self.repo);
721 }
722 out.push_str(" --release-mode ");
723 out.push_str(self.profile.release.mode.as_str());
724 if let Some(driver) = &self.profile.release.driver {
725 out.push_str(" --release-driver ");
726 out.push_str(driver);
727 }
728 if let Some(style) = self.profile.release.style {
729 out.push_str(" --release-style ");
730 out.push_str(style.as_str());
731 }
732 out.push_str(" --trunk ");
733 out.push_str(&self.git.trunk);
734 out.push_str(" --checkout-mode ");
735 out.push_str(self.git.checkout_mode.as_str());
736 out.push_str(" --integration ");
737 out.push_str(self.git.integration.as_str());
738 out.push_str(" --merge-form ");
739 out.push_str(self.git.merge_form.as_str());
740 if !self.required_check.is_empty() {
744 out.push_str(" --required-check ");
745 push_shell_word(&mut out, &self.required_check);
746 }
747 if !self.required_workflow.is_empty() {
748 out.push_str(" --required-workflow ");
749 push_shell_word(&mut out, &self.required_workflow);
750 }
751 out
752 }
753
754 #[must_use]
760 pub fn capability_flags(&self) -> String {
761 let mut out = String::new();
762 if self.capabilities.nix_packaging {
763 out.push_str(" --nix-packaging");
764 }
765 if self.capabilities.reporting_policy {
766 out.push_str(" --reporting-policy");
767 }
768 if self.capabilities.scorecard {
769 out.push_str(" --scorecard");
770 }
771 if self.workflow_guards() {
772 out.push_str(" --workflow-guards");
773 }
774 out.push_str(" --code-scanning ");
781 out.push_str(
782 self.capabilities
783 .code_scanning
784 .map_or("off", Provider::as_str),
785 );
786 out
787 }
788
789 #[must_use]
796 pub fn capability_toggles(&self) -> String {
797 let word = |on: bool| if on { "on" } else { "off" };
798 format!(
799 " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {} --workflow-guards {}",
800 word(self.capabilities.nix_packaging),
801 word(self.capabilities.reporting_policy),
802 word(self.capabilities.scorecard),
803 self.capabilities
804 .code_scanning
805 .map_or("off", Provider::as_str),
806 word(self.workflow_guards())
807 )
808 }
809}
810
811#[cfg(test)]
812impl Params {
813 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
818 Self {
819 profile: ProfileSnapshot {
820 technologies: vec!["rust".to_owned()],
821 forge: Some("github".to_owned()),
822 release: ReleaseIntent {
823 mode: ReleaseMode::Automatic,
824 driver: Some("rust".to_owned()),
825 style,
826 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
827 registry: None,
828 build: None,
829 },
830 },
831 git: GitWorkflow {
832 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
833 checkout_mode: CheckoutMode::LinkedWorktree,
834 integration: Integration::Local,
835 merge_form: MergeForm::Squash,
836 },
837 capabilities: CapabilityRequests {
838 nix_packaging: false,
839 reporting_policy: true,
840 scorecard: false,
841 code_scanning: None,
842 workflow_guards: Some(true),
843 },
844 repo: repo.to_owned(),
845 security_contact: String::new(),
846 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
847 security_scope: String::new(),
848 required_check: "gate".to_owned(),
849 required_workflow: "ci".to_owned(),
850 dev_environment: None,
851 }
852 }
853
854 pub(crate) fn for_test_security(contact: &str, response: &str, scope: &str) -> Self {
856 Self {
857 security_contact: contact.to_owned(),
858 security_response: response.to_owned(),
859 security_scope: scope.to_owned(),
860 ..Self::for_test("acme/widget", Some(Style::Trunk))
861 }
862 }
863
864 pub(crate) fn for_test_release_less(
867 technologies: &[&str],
868 forge: Option<&str>,
869 mode: ReleaseMode,
870 ) -> Self {
871 let mut params = Self::for_test("acme/widget", None);
872 params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
873 params.profile.forge = forge.map(str::to_owned);
874 params.profile.release = ReleaseIntent {
875 mode,
876 driver: None,
877 style: None,
878 line_prefix: None,
879 registry: None,
880 build: None,
881 };
882 params.capabilities.reporting_policy = false;
883 if forge.is_none() {
884 params.repo = String::new();
885 }
886 params
887 }
888
889 pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
891 self.profile.technologies = vec![driver.to_owned()];
892 self.profile.release.driver = Some(driver.to_owned());
893 self.profile.forge = Some(forge.to_owned());
894 }
895
896 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
898 self.git.checkout_mode = mode;
899 }
900
901 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
903 self.git.integration = mode;
904 }
905
906 pub(crate) const fn set_merge_form_for_test(&mut self, form: MergeForm) {
908 self.git.merge_form = form;
909 }
910
911 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
913 self.capabilities.nix_packaging = nix;
914 }
915
916 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
918 self.capabilities.scorecard = scorecard;
919 }
920
921 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
923 self.capabilities.code_scanning = provider;
924 }
925
926 pub(crate) const fn set_workflow_guards_for_test(&mut self, on: bool) {
928 self.capabilities.workflow_guards = Some(on);
929 }
930}
931
932fn invalid_release(message: impl std::fmt::Display) -> RkError {
935 RkError::Usage(format!(
936 "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
937 ))
938}
939
940#[must_use]
962pub fn gate_defaults(
963 profile: &ProfileSnapshot,
964 git: &GitWorkflow,
965) -> Option<(&'static str, &'static str)> {
966 let consuming = profile.forge.as_deref() == Some("github")
967 && profile.release.mode == ReleaseMode::Automatic
968 && profile.release.style == Some(Style::Trunk)
969 && git.integration == Integration::Local;
970 consuming.then_some(("gate", "ci"))
971}
972
973fn gate_answer(
976 record: &manifest::Manifest,
977 pick: impl Fn((&'static str, &'static str)) -> &'static str,
978 held: impl Fn(&manifest::Parameters) -> &String,
979) -> String {
980 let recorded = held(&record.parameters);
981 if recorded.is_empty() {
982 gate_defaults(&record.profile, &record.git)
983 .map_or_else(String::new, |pair| pick(pair).to_owned())
984 } else {
985 recorded.clone()
986 }
987}
988
989fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
991 chain
992 .into_iter()
993 .find_map(|(value, source)| value.map(|value| (value, source)))
994}
995
996#[allow(
1004 clippy::too_many_lines,
1005 reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
1006)]
1007pub fn resolve(
1008 target: &Utf8Path,
1009 flags: &Inputs<'_>,
1010 config: Option<&crate::config::Config>,
1011 record: Option<&manifest::Manifest>,
1012 purpose: Purpose,
1013) -> Result<Resolved, RkError> {
1014 let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
1015 let observed = crate::detect::observe(target.as_std_path());
1016 let known_drivers = catalog::known_drivers();
1017
1018 let (technologies, source) = answered([
1020 (
1021 (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
1022 Source::Flag,
1023 ),
1024 (
1025 config.and_then(|c| c.profile.technologies.clone()),
1026 Source::Config,
1027 ),
1028 (
1029 record.map(|r| r.profile.technologies.clone()),
1030 Source::Record,
1031 ),
1032 (
1033 Some(
1034 observed
1035 .technologies
1036 .iter()
1037 .map(|t| (*t).to_owned())
1038 .collect(),
1039 ),
1040 Source::Observation,
1041 ),
1042 (None, Source::Default),
1043 ])
1044 .unwrap_or_else(|| (Vec::new(), Source::Default));
1045 let technologies =
1046 canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
1047 sources.insert("profile.technologies", source);
1048
1049 let forge_flag = flags
1051 .forge
1052 .map(|name| canonical_category(name).map_err(RkError::Usage))
1053 .transpose()?;
1054 let (forge, source) = answered([
1055 (forge_flag.map(Some), Source::Flag),
1056 (
1057 config
1058 .and_then(|c| c.profile.forge.clone())
1059 .map(|value| if value.is_empty() { None } else { Some(value) }),
1060 Source::Config,
1061 ),
1062 (record.map(|r| r.profile.forge.clone()), Source::Record),
1063 (
1064 observed.forge.map(|forge| Some(forge.as_str().to_owned())),
1065 Source::Observation,
1066 ),
1067 (Some(None), Source::Default),
1068 ])
1069 .unwrap_or((None, Source::Default));
1070 let forge = forge
1071 .map(|name| canonical_category(&name).map_err(RkError::Usage))
1072 .transpose()?;
1073 sources.insert("profile.forge", source);
1074
1075 let (repo, source) = answered([
1077 (flags.repo.map(str::to_owned), Source::Flag),
1078 (
1079 config
1080 .map(|c| c.project.repo.clone())
1081 .filter(|value| !value.is_empty()),
1082 Source::Config,
1083 ),
1084 (
1085 record
1086 .map(|r| r.parameters.repo.clone())
1087 .filter(|value| !value.is_empty()),
1088 Source::Record,
1089 ),
1090 (observed.repo.clone(), Source::Observation),
1091 (None, Source::Default),
1092 ])
1093 .map_or((None, Source::Default), |(value, source)| {
1094 (Some(value), source)
1095 });
1096 sources.insert("project.repo", source);
1097
1098 let release_bearing: Vec<String> = technologies
1101 .iter()
1102 .filter(|name| known_drivers.contains(name))
1103 .cloned()
1104 .collect();
1105 let proposal = match release_bearing.as_slice() {
1106 [] => Proposal::None,
1107 [one] => Proposal::Automatic {
1108 driver: one.clone(),
1109 },
1110 many => Proposal::Ambiguous {
1111 drivers: many.to_vec(),
1112 },
1113 };
1114 let proposed_mode = match &proposal {
1121 Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
1122 Proposal::None => ReleaseMode::None,
1123 };
1124 let (mode, source) = answered([
1125 (flags.release_mode, Source::Flag),
1126 (config.and_then(|c| c.profile.release.mode), Source::Config),
1127 (record.map(|r| r.profile.release.mode), Source::Record),
1128 (Some(proposed_mode), Source::Observation),
1129 (None, Source::Default),
1130 ])
1131 .unwrap_or((ReleaseMode::None, Source::Default));
1132 let mode_source = source;
1133 sources.insert("profile.release.mode", mode_source);
1134 let mode_answered_above = mode_source != Source::Observation;
1135 let proposal = (!mode_answered_above).then_some(proposal);
1136
1137 let driver_flag = flags
1141 .release_driver
1142 .map(|name| canonical_category(name).map_err(RkError::Usage))
1143 .transpose()?;
1144 let (driver, driver_source) = answered([
1145 (driver_flag, Source::Flag),
1146 (
1147 config.and_then(|c| c.profile.release.driver.clone()),
1148 Source::Config,
1149 ),
1150 (
1151 record.and_then(|r| r.profile.release.driver.clone()),
1152 Source::Record,
1153 ),
1154 (
1155 match &proposal {
1156 Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
1157 Some(driver.clone())
1158 }
1159 _ => None,
1160 },
1161 Source::Observation,
1162 ),
1163 (None, Source::Default),
1164 ])
1165 .map_or((None, Source::Default), |(value, source)| {
1166 (Some(value), source)
1167 });
1168 let (style, style_source) = answered([
1169 (flags.style, Source::Flag),
1170 (config.and_then(|c| c.profile.release.style), Source::Config),
1171 (record.and_then(|r| r.profile.release.style), Source::Record),
1172 (None, Source::Observation),
1173 (
1174 (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
1175 .then_some(Style::Trunk),
1176 Source::Default,
1177 ),
1178 ])
1179 .map_or((None, Source::Default), |(value, source)| {
1180 (Some(value), source)
1181 });
1182 let (line_prefix, prefix_source) = answered([
1183 (None, Source::Flag),
1184 (
1185 config.and_then(|c| c.profile.release.line_prefix.clone()),
1186 Source::Config,
1187 ),
1188 (
1189 record.and_then(|r| r.profile.release.line_prefix.clone()),
1190 Source::Record,
1191 ),
1192 (None, Source::Observation),
1193 (
1194 (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1195 Source::Default,
1196 ),
1197 ])
1198 .map_or((None, Source::Default), |(value, source)| {
1199 (Some(value), source)
1200 });
1201
1202 let (registry, registry_source) = answered([
1206 (None, Source::Flag),
1207 (
1208 config.and_then(|c| c.profile.release.registry),
1209 Source::Config,
1210 ),
1211 (
1212 record.and_then(|r| r.profile.release.registry),
1213 Source::Record,
1214 ),
1215 (None, Source::Observation),
1216 (None, Source::Default),
1217 ])
1218 .map_or((None, Source::Default), |(value, source)| {
1219 (Some(value), source)
1220 });
1221
1222 let build_key = |flag: Option<&str>, read: fn(&BuildAnswer) -> Option<String>| {
1226 answered([
1227 (flag.map(str::to_owned), Source::Flag),
1228 (
1229 config.and_then(|c| c.profile.release.build.as_ref().and_then(read)),
1230 Source::Config,
1231 ),
1232 (
1233 record.and_then(|r| r.profile.release.build.as_ref().and_then(read)),
1234 Source::Record,
1235 ),
1236 (None, Source::Observation),
1237 (None, Source::Default),
1238 ])
1239 .map_or((None, Source::Default), |(value, source)| {
1240 (Some(value), source)
1241 })
1242 };
1243 let (build_command, command_source) =
1244 build_key(flags.build_command, |build| build.command.clone());
1245 let (build_packages, packages_source) =
1246 build_key(flags.build_packages, |build| build.packages.clone());
1247 if let Some(command) = &build_command {
1248 crate::config::build_command_shape(command).map_err(crate::config::invalid)?;
1249 }
1250 if let Some(packages) = &build_packages {
1251 crate::config::build_packages_shape(packages).map_err(crate::config::invalid)?;
1252 }
1253 let build = (build_command.is_some() || build_packages.is_some()).then_some(BuildAnswer {
1254 command: build_command,
1255 packages: build_packages,
1256 });
1257
1258 let reporting = purpose == Purpose::Preview;
1263 let release = match mode {
1264 ReleaseMode::Automatic => {
1265 if let Some(Proposal::Ambiguous { drivers }) = &proposal
1266 && driver.is_none()
1267 && !reporting
1268 {
1269 return Err(RkError::Usage(format!(
1270 "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1271 drivers.join(" and "),
1272 crate::config::CONFIG_PATH
1273 )));
1274 }
1275 if forge.is_none() && !reporting {
1276 let message = observed.host.map_or_else(
1277 || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1278 |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1279 );
1280 return Err(RkError::refusal(
1281 Diagnostic::new(Reason::ForgeUndetected, message)
1282 .expected("a github.com or gitlab remote, or --forge")
1283 .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1284 ));
1285 }
1286 if driver.is_none() && !reporting {
1287 return Err(invalid_release(format!(
1288 "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1289 known_drivers.join("|")
1290 )));
1291 }
1292 if let Some(driver) = &driver
1293 && !technologies.contains(driver)
1294 && !reporting
1295 {
1296 return Err(invalid_release(format!(
1297 "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1298 if technologies.is_empty() {
1299 "empty".to_owned()
1300 } else {
1301 technologies.join(", ")
1302 }
1303 )));
1304 }
1305 let style = match (style, purpose) {
1306 (None, Purpose::Upgrade | Purpose::Adopt) => {
1307 return Err(RkError::Usage(
1308 "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1309 ));
1310 }
1311 (None, _) => Style::Trunk,
1312 (Some(style), _) => style,
1313 };
1314 if registry.is_some() && (driver.is_some() || !reporting) {
1317 catalog::registry_answer_fits(mode, driver.as_deref()).map_err(RkError::Usage)?;
1318 }
1319 if build.is_some() && (driver.is_some() || !reporting) {
1320 catalog::build_answer_fits(mode, driver.as_deref()).map_err(RkError::Usage)?;
1321 }
1322 sources.insert("profile.release.driver", driver_source);
1323 sources.insert("profile.release.style", style_source);
1324 sources.insert("profile.release.line_prefix", prefix_source);
1325 if driver.as_deref() == Some(RegistryAnswer::DRIVER) {
1326 sources.insert("profile.release.registry", registry_source);
1327 }
1328 if driver.as_deref() == Some(BuildAnswer::DRIVER) {
1329 sources.insert("profile.release.build.command", command_source);
1330 sources.insert("profile.release.build.packages", packages_source);
1331 }
1332 ReleaseIntent {
1333 mode,
1334 driver,
1335 style: Some(style),
1336 line_prefix: Some(
1337 line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1338 ),
1339 registry,
1340 build,
1341 }
1342 }
1343 ReleaseMode::External | ReleaseMode::None => {
1344 for (key, present, value_source) in [
1351 ("profile.release.driver", driver.is_some(), driver_source),
1352 ("profile.release.style", style.is_some(), style_source),
1353 (
1354 "profile.release.line_prefix",
1355 line_prefix.is_some(),
1356 prefix_source,
1357 ),
1358 (
1359 "profile.release.registry",
1360 registry.is_some(),
1361 registry_source,
1362 ),
1363 (
1364 "profile.release.build.command",
1365 build.as_ref().is_some_and(|build| build.command.is_some()),
1366 command_source,
1367 ),
1368 (
1369 "profile.release.build.packages",
1370 build.as_ref().is_some_and(|build| build.packages.is_some()),
1371 packages_source,
1372 ),
1373 ] {
1374 if present
1375 && matches!(value_source, Source::Flag | Source::Config)
1376 && value_source.rank() <= mode_source.rank()
1377 {
1378 return Err(invalid_release(format!(
1379 "{key} is set while profile.release.mode is {}",
1380 mode.as_str()
1381 )));
1382 }
1383 }
1384 ReleaseIntent {
1385 mode,
1386 driver: None,
1387 style: None,
1388 line_prefix: None,
1389 registry: None,
1390 build: None,
1391 }
1392 }
1393 };
1394
1395 let adapter_known = forge
1399 .as_deref()
1400 .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1401 let repo = match (forge.is_some(), adapter_known, repo) {
1402 (false, _, _) => String::new(),
1405 (true, false, repo) => repo.unwrap_or_default(),
1406 (true, true, Some(repo)) => repo,
1407 (true, true, None) if purpose == Purpose::Preview => {
1408 crate::projection::REPO_PLACEHOLDER.to_owned()
1409 }
1410 (true, true, None) => return Err(crate::landing::repo_unresolved()),
1411 };
1412
1413 let (trunk, source) = answered([
1415 (flags.trunk.map(str::to_owned), Source::Flag),
1416 (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1417 (record.map(|r| r.git.trunk.clone()), Source::Record),
1418 (None, Source::Observation),
1419 (
1420 Some(crate::config::TRUNK_DEFAULT.to_owned()),
1421 Source::Default,
1422 ),
1423 ])
1424 .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1425 sources.insert("git.trunk", source);
1426 let (checkout_mode, source) = answered([
1427 (flags.checkout_mode, Source::Flag),
1428 (config.and_then(|c| c.git.checkout_mode), Source::Config),
1429 (record.map(|r| r.git.checkout_mode), Source::Record),
1430 (None, Source::Observation),
1431 (
1432 Some(if purpose == Purpose::Adopt {
1433 CheckoutMode::MainWorktree
1434 } else {
1435 CheckoutMode::LinkedWorktree
1436 }),
1437 Source::Default,
1438 ),
1439 ])
1440 .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1441 sources.insert("git.checkout_mode", source);
1442 let (integration, source) = answered([
1447 (flags.integration, Source::Flag),
1448 (config.and_then(|c| c.git.integration), Source::Config),
1449 (record.map(|r| r.git.integration), Source::Record),
1450 (None, Source::Observation),
1451 (
1456 Some(if purpose == Purpose::Adopt {
1457 Integration::Forge
1458 } else {
1459 Integration::Local
1460 }),
1461 Source::Default,
1462 ),
1463 ])
1464 .unwrap_or((Integration::Local, Source::Default));
1465 sources.insert("git.integration", source);
1466 let (merge_form, source) = answered([
1470 (flags.merge_form, Source::Flag),
1471 (config.and_then(|c| c.git.merge_form), Source::Config),
1472 (record.map(|r| r.git.merge_form), Source::Record),
1473 (None, Source::Observation),
1474 (Some(manifest::merge_form_squash()), Source::Default),
1475 ])
1476 .unwrap_or((MergeForm::Squash, Source::Default));
1477 sources.insert("git.merge_form", source);
1478
1479 let (nix_packaging, source) = answered([
1481 (flags.nix, Source::Flag),
1482 (
1483 config.and_then(|c| c.capabilities.nix_packaging),
1484 Source::Config,
1485 ),
1486 (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1487 (None, Source::Observation),
1488 (Some(false), Source::Default),
1489 ])
1490 .unwrap_or((false, Source::Default));
1491 sources.insert("capabilities.nix_packaging", source);
1492 let (reporting_policy, source) = answered([
1493 (flags.reporting_policy, Source::Flag),
1494 (
1495 config.and_then(|c| c.capabilities.reporting_policy),
1496 Source::Config,
1497 ),
1498 (
1499 record.map(|r| r.capabilities.reporting_policy),
1500 Source::Record,
1501 ),
1502 (None, Source::Observation),
1503 (
1506 Some(release.mode == ReleaseMode::Automatic),
1507 Source::Default,
1508 ),
1509 ])
1510 .unwrap_or((false, Source::Default));
1511 sources.insert("capabilities.reporting_policy", source);
1512 let (scorecard, source) = answered([
1513 (flags.scorecard, Source::Flag),
1514 (
1515 config.and_then(|c| c.capabilities.scorecard),
1516 Source::Config,
1517 ),
1518 (record.map(|r| r.capabilities.scorecard), Source::Record),
1519 (None, Source::Observation),
1520 (Some(false), Source::Default),
1521 ])
1522 .unwrap_or((false, Source::Default));
1523 sources.insert("capabilities.scorecard", source);
1524 let configured_scanning = config
1525 .and_then(|c| c.capabilities.code_scanning.as_deref())
1526 .map(Provider::parse)
1527 .transpose()?;
1528 let (code_scanning, source) = answered([
1529 (flags.code_scanning, Source::Flag),
1530 (configured_scanning, Source::Config),
1531 (record.map(|r| r.capabilities.code_scanning), Source::Record),
1532 (None, Source::Observation),
1533 (Some(None), Source::Default),
1534 ])
1535 .unwrap_or((None, Source::Default));
1536 sources.insert("capabilities.code_scanning", source);
1537 let (workflow_guards, source) = answered([
1542 (flags.workflow_guards, Source::Flag),
1543 (
1544 config.and_then(|c| c.capabilities.workflow_guards),
1545 Source::Config,
1546 ),
1547 (
1548 record.map(|r| r.capabilities.workflow_guards()),
1549 Source::Record,
1550 ),
1551 (None, Source::Observation),
1552 (Some(false), Source::Default),
1553 ])
1554 .unwrap_or((false, Source::Default));
1555 sources.insert("capabilities.workflow_guards", source);
1556 let (security_contact, source) = answered([
1564 (None, Source::Flag),
1565 (
1566 config.and_then(|c| c.security.contact.clone()),
1567 Source::Config,
1568 ),
1569 (
1570 record.map(|r| r.parameters.security_contact.clone()),
1571 Source::Record,
1572 ),
1573 (None, Source::Observation),
1574 (Some(String::new()), Source::Default),
1575 ])
1576 .unwrap_or((String::new(), Source::Default));
1577 let security_contact =
1578 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1579 sources.insert("security.contact", source);
1580 let (security_response, source) = answered([
1581 (None, Source::Flag),
1582 (
1583 config.and_then(|c| c.security.response.clone()),
1584 Source::Config,
1585 ),
1586 (
1587 record.map(|r| r.parameters.security_response.clone()),
1588 Source::Record,
1589 ),
1590 (None, Source::Observation),
1591 (
1592 Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1593 Source::Default,
1594 ),
1595 ])
1596 .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1597 let security_response =
1598 crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1599 sources.insert("security.response", source);
1600 let (security_scope, source) = answered([
1601 (None, Source::Flag),
1602 (
1603 config.and_then(|c| c.security.scope.clone()),
1604 Source::Config,
1605 ),
1606 (
1607 record.map(|r| r.parameters.security_scope.clone()),
1608 Source::Record,
1609 ),
1610 (None, Source::Observation),
1611 (Some(String::new()), Source::Default),
1612 ])
1613 .unwrap_or((String::new(), Source::Default));
1614 let security_scope =
1615 crate::config::canonical_scope(&security_scope).map_err(crate::config::invalid)?;
1616 sources.insert("security.scope", source);
1617
1618 let gate_default = gate_defaults(
1623 &ProfileSnapshot {
1624 technologies: technologies.clone(),
1625 forge: forge.clone(),
1626 release: release.clone(),
1627 },
1628 &GitWorkflow {
1629 trunk: trunk.clone(),
1630 checkout_mode,
1631 integration,
1632 merge_form,
1633 },
1634 );
1635 let (required_check, source) = answered([
1636 (flags.required_check.map(str::to_owned), Source::Flag),
1637 (
1638 config
1639 .map(|c| c.setup.required_check.clone())
1640 .filter(|name| !name.is_empty()),
1641 Source::Config,
1642 ),
1643 (
1644 record
1645 .map(|r| r.parameters.required_check.clone())
1646 .filter(|name| !name.is_empty()),
1647 Source::Record,
1648 ),
1649 (None, Source::Observation),
1650 (
1651 Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1652 Source::Default,
1653 ),
1654 ])
1655 .unwrap_or((String::new(), Source::Default));
1656 sources.insert("setup.required_check", source);
1657 let (required_workflow, source) = answered([
1658 (flags.required_workflow.map(str::to_owned), Source::Flag),
1659 (
1660 config
1661 .map(|c| c.setup.required_workflow.clone())
1662 .filter(|name| !name.is_empty()),
1663 Source::Config,
1664 ),
1665 (
1666 record
1667 .map(|r| r.parameters.required_workflow.clone())
1668 .filter(|name| !name.is_empty()),
1669 Source::Record,
1670 ),
1671 (None, Source::Observation),
1672 (
1673 Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1674 Source::Default,
1675 ),
1676 ])
1677 .unwrap_or((String::new(), Source::Default));
1678 sources.insert("setup.required_workflow", source);
1679 let recorded = || {
1683 record
1684 .and_then(|r| r.parameters.dev_environment.clone())
1685 .map_or((None, Source::Default), |answer| {
1686 (Some(answer), Source::Record)
1687 })
1688 };
1689 let (dev_environment, source) = config
1690 .and_then(|c| c.dev_environment.clone())
1691 .map_or_else(recorded, |answer| (answer.answered(), Source::Config));
1692 if let Some(answer) = &dev_environment {
1693 answer
1694 .check_forge(forge.as_deref())
1695 .map_err(crate::config::invalid)?;
1696 }
1697 sources.insert("dev_environment", source);
1698 if forge.as_deref() == Some("gitlab") {
1703 for (flag, supplied) in [
1704 ("--required-check", flags.required_check),
1705 ("--required-workflow", flags.required_workflow),
1706 ] {
1707 if supplied.is_some() {
1708 return Err(RkError::Usage(format!(
1709 "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1710 )));
1711 }
1712 }
1713 }
1714
1715 let mut unknown: Vec<String> = technologies
1716 .iter()
1717 .filter(|name| !known_drivers.contains(name))
1718 .map(|name| format!("technology {name}"))
1719 .collect();
1720 if let Some(name) = &forge
1721 && crate::detect::Forge::parse(name).is_none()
1722 {
1723 unknown.push(format!("forge {name}"));
1724 }
1725
1726 let (bot_name, source) = config
1729 .map(|c| c.setup.bot.name.trim().to_owned())
1730 .filter(|name| !name.is_empty())
1731 .map_or((String::new(), Source::Default), |name| {
1732 (name, Source::Config)
1733 });
1734 sources.insert("setup.bot.name", source);
1735
1736 Ok(Resolved {
1737 params: Params {
1738 profile: ProfileSnapshot {
1739 technologies,
1740 forge,
1741 release,
1742 },
1743 git: GitWorkflow {
1744 trunk,
1745 checkout_mode,
1746 integration,
1747 merge_form,
1748 },
1749 capabilities: CapabilityRequests {
1750 nix_packaging,
1751 reporting_policy,
1752 scorecard,
1753 code_scanning,
1754 workflow_guards: Some(workflow_guards),
1755 },
1756 repo,
1757 security_contact,
1758 security_response,
1759 security_scope,
1760 required_check,
1761 required_workflow,
1762 dev_environment,
1763 },
1764 sources,
1765 unknown,
1766 proposal,
1767 bot_name,
1768 })
1769}