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)]
82#[serde(rename_all = "kebab-case")]
83pub enum RegistryAnswer {
84 CratesIo,
86 Pypi,
88 None,
91}
92
93impl RegistryAnswer {
94 #[must_use]
96 pub const fn as_str(self) -> &'static str {
97 match self {
98 Self::CratesIo => "crates-io",
99 Self::Pypi => "pypi",
100 Self::None => "none",
101 }
102 }
103
104 #[must_use]
107 pub fn default_for(driver: &str) -> Option<Self> {
108 match driver {
109 "rust" => Some(Self::CratesIo),
110 "python" => Some(Self::Pypi),
111 _ => None,
112 }
113 }
114
115 #[must_use]
117 pub fn fits(self, driver: &str) -> bool {
118 Self::default_for(driver).is_some_and(|default| self == Self::None || self == default)
119 }
120
121 pub const SHAPE: &'static str = "crates-io or none for profile.release.driver = \"rust\", and pypi or none for profile.release.driver = \"python\"";
123}
124
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(deny_unknown_fields, default)]
134pub struct BuildAnswer {
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub command: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub packages: Option<String>,
141}
142
143impl BuildAnswer {
144 pub const DRIVER: &'static str = "bash";
146 pub const COMMAND: &'static str = "make dist";
148 pub const PACKAGES: &'static str = "make";
150
151 #[must_use]
153 pub const fn is_empty(&self) -> bool {
154 self.command.is_none() && self.packages.is_none()
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct ReleaseIntent {
164 pub mode: ReleaseMode,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub driver: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub style: Option<Style>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub line_prefix: Option<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub registry: Option<RegistryAnswer>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub build: Option<BuildAnswer>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191pub struct ProfileSnapshot {
192 pub technologies: Vec<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub forge: Option<String>,
197 pub release: ReleaseIntent,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct GitWorkflow {
204 pub trunk: String,
206 pub checkout_mode: CheckoutMode,
208 #[serde(default = "crate::landing::manifest::integration_forge")]
211 pub integration: Integration,
212 #[serde(default = "crate::landing::manifest::merge_form_squash")]
215 pub merge_form: MergeForm,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220pub struct CapabilityRequests {
221 #[serde(default)]
223 pub nix_packaging: bool,
224 #[serde(default)]
226 pub reporting_policy: bool,
227 #[serde(default)]
229 pub scorecard: bool,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub code_scanning: Option<Provider>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub workflow_guards: Option<bool>,
239}
240
241impl CapabilityRequests {
242 #[must_use]
246 pub fn workflow_guards(&self) -> bool {
247 self.workflow_guards.unwrap_or(true)
248 }
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
253#[serde(rename_all = "lowercase")]
254pub enum Source {
255 Flag,
257 Config,
259 Record,
261 Observation,
263 Default,
265}
266
267impl Source {
268 #[must_use]
274 pub const fn rank(self) -> u8 {
275 match self {
276 Self::Flag => 0,
277 Self::Config => 1,
278 Self::Record => 2,
279 Self::Observation => 3,
280 Self::Default => 4,
281 }
282 }
283
284 #[must_use]
286 pub const fn as_str(self) -> &'static str {
287 match self {
288 Self::Flag => "flag",
289 Self::Config => "config",
290 Self::Record => "record",
291 Self::Observation => "observation",
292 Self::Default => "default",
293 }
294 }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300#[serde(rename_all = "kebab-case", tag = "state")]
301pub enum Proposal {
302 None,
304 Automatic {
306 driver: String,
308 },
309 Ambiguous {
312 drivers: Vec<String>,
314 },
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct Params {
323 profile: ProfileSnapshot,
324 git: GitWorkflow,
325 capabilities: CapabilityRequests,
326 repo: String,
327 security_contact: String,
328 security_response: String,
329 security_scope: String,
330 required_check: String,
331 required_workflow: String,
332 dev_environment: Option<DevEnvironment>,
333}
334
335#[derive(Default)]
337pub struct Inputs<'a> {
338 pub technologies: &'a [String],
341 pub forge: Option<&'a str>,
343 pub repo: Option<&'a str>,
345 pub release_mode: Option<ReleaseMode>,
347 pub release_driver: Option<&'a str>,
349 pub style: Option<Style>,
351 pub trunk: Option<&'a str>,
353 pub checkout_mode: Option<CheckoutMode>,
355 pub integration: Option<Integration>,
357 pub merge_form: Option<MergeForm>,
359 pub required_check: Option<&'a str>,
361 pub required_workflow: Option<&'a str>,
364 pub build_command: Option<&'a str>,
366 pub build_packages: Option<&'a str>,
368 pub nix: Option<bool>,
370 pub reporting_policy: Option<bool>,
372 pub scorecard: Option<bool>,
374 pub code_scanning: Option<Option<Provider>>,
377 pub workflow_guards: Option<bool>,
379}
380
381#[derive(Clone, Copy, PartialEq, Eq)]
383pub enum Purpose {
384 Init,
386 Preview,
389 Upgrade,
391 Adopt,
393}
394
395#[derive(Debug, Clone)]
397pub struct Resolved {
398 pub params: Params,
400 pub sources: BTreeMap<&'static str, Source>,
402 pub unknown: Vec<String>,
404 pub proposal: Option<Proposal>,
407 pub bot_name: String,
411}
412
413pub fn canonical_category(raw: &str) -> Result<String, String> {
423 let lowered = raw.trim().to_ascii_lowercase();
424 let shaped = lowered
425 .chars()
426 .next()
427 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
428 && lowered
429 .chars()
430 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
431 if shaped {
432 Ok(lowered)
433 } else {
434 Err(format!(
435 "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
436 ))
437 }
438}
439
440pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
446 let mut out = Vec::with_capacity(raw.len());
447 for value in raw {
448 let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
449 if out.contains(&name) {
450 return Err(format!("{key} names {name} twice; each technology once"));
451 }
452 out.push(name);
453 }
454 out.sort();
455 Ok(out)
456}
457
458fn push_shell_word(out: &mut String, value: &str) {
461 let inert = !value.is_empty()
462 && value.bytes().all(|byte| {
463 byte.is_ascii_alphanumeric()
464 || matches!(
465 byte,
466 b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
467 )
468 });
469 if inert {
470 out.push_str(value);
471 return;
472 }
473 out.push('\'');
474 out.push_str(&value.replace('\'', "'\"'\"'"));
475 out.push('\'');
476}
477
478impl Params {
479 #[must_use]
482 pub fn from_record(record: &manifest::Manifest) -> Self {
483 Self {
484 profile: record.profile.clone(),
485 git: record.git.clone(),
486 capabilities: record.capabilities.clone(),
487 repo: record.parameters.repo.clone(),
488 security_contact: record.parameters.security_contact.clone(),
489 security_response: record.parameters.security_response.clone(),
490 security_scope: record.parameters.security_scope.clone(),
491 required_check: gate_answer(
494 record,
495 |(check, _)| check,
496 |parameters| ¶meters.required_check,
497 ),
498 required_workflow: gate_answer(
499 record,
500 |(_, workflow)| workflow,
501 |parameters| ¶meters.required_workflow,
502 ),
503 dev_environment: record.parameters.dev_environment.clone(),
504 }
505 }
506
507 pub fn resolve(
515 target: &Utf8Path,
516 flags: &Inputs<'_>,
517 config: Option<&crate::config::Config>,
518 record: Option<&manifest::Manifest>,
519 purpose: Purpose,
520 ) -> Result<Self, RkError> {
521 resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
522 }
523
524 #[must_use]
526 pub const fn profile(&self) -> &ProfileSnapshot {
527 &self.profile
528 }
529
530 #[must_use]
532 pub const fn git(&self) -> &GitWorkflow {
533 &self.git
534 }
535
536 #[must_use]
538 pub const fn capabilities(&self) -> &CapabilityRequests {
539 &self.capabilities
540 }
541
542 #[must_use]
544 pub fn technologies(&self) -> &[String] {
545 &self.profile.technologies
546 }
547
548 #[must_use]
550 pub fn forge(&self) -> Option<&str> {
551 self.profile.forge.as_deref()
552 }
553
554 #[must_use]
556 pub const fn release_mode(&self) -> ReleaseMode {
557 self.profile.release.mode
558 }
559
560 #[must_use]
562 pub fn driver(&self) -> Option<&str> {
563 self.profile.release.driver.as_deref()
564 }
565
566 #[must_use]
568 pub const fn style(&self) -> Option<Style> {
569 self.profile.release.style
570 }
571
572 #[must_use]
576 pub fn registry(&self) -> Option<RegistryAnswer> {
577 if self.release_mode() != ReleaseMode::Automatic {
578 return None;
579 }
580 let default = RegistryAnswer::default_for(self.driver()?)?;
581 Some(self.profile.release.registry.unwrap_or(default))
582 }
583
584 #[must_use]
587 pub fn build_packages(&self) -> &str {
588 self.profile
589 .release
590 .build
591 .as_ref()
592 .and_then(|build| build.packages.as_deref())
593 .unwrap_or(BuildAnswer::PACKAGES)
594 }
595
596 #[must_use]
598 pub const fn nix_packaging(&self) -> bool {
599 self.capabilities.nix_packaging
600 }
601
602 #[must_use]
604 pub const fn reporting_policy(&self) -> bool {
605 self.capabilities.reporting_policy
606 }
607
608 #[must_use]
610 pub const fn scorecard(&self) -> bool {
611 self.capabilities.scorecard
612 }
613
614 #[must_use]
616 pub const fn code_scanning(&self) -> Option<Provider> {
617 self.capabilities.code_scanning
618 }
619
620 #[must_use]
622 pub fn workflow_guards(&self) -> bool {
623 self.capabilities.workflow_guards()
624 }
625
626 #[must_use]
629 pub fn repo(&self) -> &str {
630 &self.repo
631 }
632
633 #[must_use]
635 pub const fn checkout_mode(&self) -> CheckoutMode {
636 self.git.checkout_mode
637 }
638
639 #[must_use]
641 pub const fn integration(&self) -> Integration {
642 self.git.integration
643 }
644
645 #[must_use]
647 pub const fn merge_form(&self) -> MergeForm {
648 self.git.merge_form
649 }
650
651 #[must_use]
653 pub fn trunk(&self) -> &str {
654 &self.git.trunk
655 }
656
657 #[must_use]
660 pub fn line_prefix(&self) -> &str {
661 self.profile
662 .release
663 .line_prefix
664 .as_deref()
665 .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
666 }
667
668 #[must_use]
671 pub fn security_contact(&self) -> &str {
672 &self.security_contact
673 }
674
675 #[must_use]
677 pub fn security_response(&self) -> &str {
678 &self.security_response
679 }
680
681 #[must_use]
684 pub fn security_scope(&self) -> &str {
685 &self.security_scope
686 }
687
688 #[must_use]
691 pub fn required_check(&self) -> &str {
692 &self.required_check
693 }
694
695 #[must_use]
698 pub const fn dev_environment(&self) -> Option<&DevEnvironment> {
699 self.dev_environment.as_ref()
700 }
701
702 #[must_use]
705 pub fn required_workflow(&self) -> &str {
706 &self.required_workflow
707 }
708
709 #[must_use]
713 pub fn canonical_flags(&self) -> String {
714 let mut out = String::new();
715 for technology in &self.profile.technologies {
716 out.push_str(" --technology ");
717 out.push_str(technology);
718 }
719 if let Some(forge) = &self.profile.forge {
720 out.push_str(" --forge ");
721 out.push_str(forge);
722 }
723 if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
727 out.push_str(" --repo ");
728 out.push_str(&self.repo);
729 }
730 out.push_str(" --release-mode ");
731 out.push_str(self.profile.release.mode.as_str());
732 if let Some(driver) = &self.profile.release.driver {
733 out.push_str(" --release-driver ");
734 out.push_str(driver);
735 }
736 if let Some(style) = self.profile.release.style {
737 out.push_str(" --release-style ");
738 out.push_str(style.as_str());
739 }
740 out.push_str(" --trunk ");
741 out.push_str(&self.git.trunk);
742 out.push_str(" --checkout-mode ");
743 out.push_str(self.git.checkout_mode.as_str());
744 out.push_str(" --integration ");
745 out.push_str(self.git.integration.as_str());
746 out.push_str(" --merge-form ");
747 out.push_str(self.git.merge_form.as_str());
748 if !self.required_check.is_empty() {
752 out.push_str(" --required-check ");
753 push_shell_word(&mut out, &self.required_check);
754 }
755 if !self.required_workflow.is_empty() {
756 out.push_str(" --required-workflow ");
757 push_shell_word(&mut out, &self.required_workflow);
758 }
759 out
760 }
761
762 #[must_use]
768 pub fn capability_flags(&self) -> String {
769 let mut out = String::new();
770 if self.capabilities.nix_packaging {
771 out.push_str(" --nix-packaging");
772 }
773 if self.capabilities.reporting_policy {
774 out.push_str(" --reporting-policy");
775 }
776 if self.capabilities.scorecard {
777 out.push_str(" --scorecard");
778 }
779 if self.workflow_guards() {
780 out.push_str(" --workflow-guards");
781 }
782 out.push_str(" --code-scanning ");
789 out.push_str(
790 self.capabilities
791 .code_scanning
792 .map_or("off", Provider::as_str),
793 );
794 out
795 }
796
797 #[must_use]
804 pub fn capability_toggles(&self) -> String {
805 let word = |on: bool| if on { "on" } else { "off" };
806 format!(
807 " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {} --workflow-guards {}",
808 word(self.capabilities.nix_packaging),
809 word(self.capabilities.reporting_policy),
810 word(self.capabilities.scorecard),
811 self.capabilities
812 .code_scanning
813 .map_or("off", Provider::as_str),
814 word(self.workflow_guards())
815 )
816 }
817}
818
819#[cfg(test)]
820impl Params {
821 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
826 Self {
827 profile: ProfileSnapshot {
828 technologies: vec!["rust".to_owned()],
829 forge: Some("github".to_owned()),
830 release: ReleaseIntent {
831 mode: ReleaseMode::Automatic,
832 driver: Some("rust".to_owned()),
833 style,
834 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
835 registry: None,
836 build: None,
837 },
838 },
839 git: GitWorkflow {
840 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
841 checkout_mode: CheckoutMode::LinkedWorktree,
842 integration: Integration::Local,
843 merge_form: MergeForm::Squash,
844 },
845 capabilities: CapabilityRequests {
846 nix_packaging: false,
847 reporting_policy: true,
848 scorecard: false,
849 code_scanning: None,
850 workflow_guards: Some(true),
851 },
852 repo: repo.to_owned(),
853 security_contact: String::new(),
854 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
855 security_scope: String::new(),
856 required_check: "gate".to_owned(),
857 required_workflow: "ci".to_owned(),
858 dev_environment: None,
859 }
860 }
861
862 pub(crate) fn for_test_security(contact: &str, response: &str, scope: &str) -> Self {
864 Self {
865 security_contact: contact.to_owned(),
866 security_response: response.to_owned(),
867 security_scope: scope.to_owned(),
868 ..Self::for_test("acme/widget", Some(Style::Trunk))
869 }
870 }
871
872 pub(crate) fn for_test_release_less(
875 technologies: &[&str],
876 forge: Option<&str>,
877 mode: ReleaseMode,
878 ) -> Self {
879 let mut params = Self::for_test("acme/widget", None);
880 params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
881 params.profile.forge = forge.map(str::to_owned);
882 params.profile.release = ReleaseIntent {
883 mode,
884 driver: None,
885 style: None,
886 line_prefix: None,
887 registry: None,
888 build: None,
889 };
890 params.capabilities.reporting_policy = false;
891 if forge.is_none() {
892 params.repo = String::new();
893 }
894 params
895 }
896
897 pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
899 self.profile.technologies = vec![driver.to_owned()];
900 self.profile.release.driver = Some(driver.to_owned());
901 self.profile.forge = Some(forge.to_owned());
902 }
903
904 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
906 self.git.checkout_mode = mode;
907 }
908
909 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
911 self.git.integration = mode;
912 }
913
914 pub(crate) const fn set_merge_form_for_test(&mut self, form: MergeForm) {
916 self.git.merge_form = form;
917 }
918
919 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
921 self.capabilities.nix_packaging = nix;
922 }
923
924 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
926 self.capabilities.scorecard = scorecard;
927 }
928
929 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
931 self.capabilities.code_scanning = provider;
932 }
933
934 pub(crate) const fn set_workflow_guards_for_test(&mut self, on: bool) {
936 self.capabilities.workflow_guards = Some(on);
937 }
938}
939
940fn invalid_release(message: impl std::fmt::Display) -> RkError {
943 RkError::Usage(format!(
944 "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
945 ))
946}
947
948#[must_use]
970pub fn gate_defaults(
971 profile: &ProfileSnapshot,
972 git: &GitWorkflow,
973) -> Option<(&'static str, &'static str)> {
974 let consuming = profile.forge.as_deref() == Some("github")
975 && profile.release.mode == ReleaseMode::Automatic
976 && profile.release.style == Some(Style::Trunk)
977 && git.integration == Integration::Local;
978 consuming.then_some(("gate", "ci"))
979}
980
981fn gate_answer(
984 record: &manifest::Manifest,
985 pick: impl Fn((&'static str, &'static str)) -> &'static str,
986 held: impl Fn(&manifest::Parameters) -> &String,
987) -> String {
988 let recorded = held(&record.parameters);
989 if recorded.is_empty() {
990 gate_defaults(&record.profile, &record.git)
991 .map_or_else(String::new, |pair| pick(pair).to_owned())
992 } else {
993 recorded.clone()
994 }
995}
996
997fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
999 chain
1000 .into_iter()
1001 .find_map(|(value, source)| value.map(|value| (value, source)))
1002}
1003
1004#[allow(
1012 clippy::too_many_lines,
1013 reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
1014)]
1015pub fn resolve(
1016 target: &Utf8Path,
1017 flags: &Inputs<'_>,
1018 config: Option<&crate::config::Config>,
1019 record: Option<&manifest::Manifest>,
1020 purpose: Purpose,
1021) -> Result<Resolved, RkError> {
1022 let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
1023 let observed = crate::detect::observe(target.as_std_path());
1024 let known_drivers = catalog::known_drivers();
1025
1026 let (technologies, source) = answered([
1028 (
1029 (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
1030 Source::Flag,
1031 ),
1032 (
1033 config.and_then(|c| c.profile.technologies.clone()),
1034 Source::Config,
1035 ),
1036 (
1037 record.map(|r| r.profile.technologies.clone()),
1038 Source::Record,
1039 ),
1040 (
1041 Some(
1042 observed
1043 .technologies
1044 .iter()
1045 .map(|t| (*t).to_owned())
1046 .collect(),
1047 ),
1048 Source::Observation,
1049 ),
1050 (None, Source::Default),
1051 ])
1052 .unwrap_or_else(|| (Vec::new(), Source::Default));
1053 let technologies =
1054 canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
1055 sources.insert("profile.technologies", source);
1056
1057 let forge_flag = flags
1059 .forge
1060 .map(|name| canonical_category(name).map_err(RkError::Usage))
1061 .transpose()?;
1062 let (forge, source) = answered([
1063 (forge_flag.map(Some), Source::Flag),
1064 (
1065 config
1066 .and_then(|c| c.profile.forge.clone())
1067 .map(|value| if value.is_empty() { None } else { Some(value) }),
1068 Source::Config,
1069 ),
1070 (record.map(|r| r.profile.forge.clone()), Source::Record),
1071 (
1072 observed.forge.map(|forge| Some(forge.as_str().to_owned())),
1073 Source::Observation,
1074 ),
1075 (Some(None), Source::Default),
1076 ])
1077 .unwrap_or((None, Source::Default));
1078 let forge = forge
1079 .map(|name| canonical_category(&name).map_err(RkError::Usage))
1080 .transpose()?;
1081 sources.insert("profile.forge", source);
1082
1083 let (repo, source) = answered([
1085 (flags.repo.map(str::to_owned), Source::Flag),
1086 (
1087 config
1088 .map(|c| c.project.repo.clone())
1089 .filter(|value| !value.is_empty()),
1090 Source::Config,
1091 ),
1092 (
1093 record
1094 .map(|r| r.parameters.repo.clone())
1095 .filter(|value| !value.is_empty()),
1096 Source::Record,
1097 ),
1098 (observed.repo.clone(), Source::Observation),
1099 (None, Source::Default),
1100 ])
1101 .map_or((None, Source::Default), |(value, source)| {
1102 (Some(value), source)
1103 });
1104 sources.insert("project.repo", source);
1105
1106 let release_bearing: Vec<String> = technologies
1109 .iter()
1110 .filter(|name| known_drivers.contains(name))
1111 .cloned()
1112 .collect();
1113 let proposal = match release_bearing.as_slice() {
1114 [] => Proposal::None,
1115 [one] => Proposal::Automatic {
1116 driver: one.clone(),
1117 },
1118 many => Proposal::Ambiguous {
1119 drivers: many.to_vec(),
1120 },
1121 };
1122 let proposed_mode = match &proposal {
1129 Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
1130 Proposal::None => ReleaseMode::None,
1131 };
1132 let (mode, source) = answered([
1133 (flags.release_mode, Source::Flag),
1134 (config.and_then(|c| c.profile.release.mode), Source::Config),
1135 (record.map(|r| r.profile.release.mode), Source::Record),
1136 (Some(proposed_mode), Source::Observation),
1137 (None, Source::Default),
1138 ])
1139 .unwrap_or((ReleaseMode::None, Source::Default));
1140 let mode_source = source;
1141 sources.insert("profile.release.mode", mode_source);
1142 let mode_answered_above = mode_source != Source::Observation;
1143 let proposal = (!mode_answered_above).then_some(proposal);
1144
1145 let driver_flag = flags
1149 .release_driver
1150 .map(|name| canonical_category(name).map_err(RkError::Usage))
1151 .transpose()?;
1152 let (driver, driver_source) = answered([
1153 (driver_flag, Source::Flag),
1154 (
1155 config.and_then(|c| c.profile.release.driver.clone()),
1156 Source::Config,
1157 ),
1158 (
1159 record.and_then(|r| r.profile.release.driver.clone()),
1160 Source::Record,
1161 ),
1162 (
1163 match &proposal {
1164 Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
1165 Some(driver.clone())
1166 }
1167 _ => None,
1168 },
1169 Source::Observation,
1170 ),
1171 (None, Source::Default),
1172 ])
1173 .map_or((None, Source::Default), |(value, source)| {
1174 (Some(value), source)
1175 });
1176 let (style, style_source) = answered([
1177 (flags.style, Source::Flag),
1178 (config.and_then(|c| c.profile.release.style), Source::Config),
1179 (record.and_then(|r| r.profile.release.style), Source::Record),
1180 (None, Source::Observation),
1181 (
1182 (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
1183 .then_some(Style::Trunk),
1184 Source::Default,
1185 ),
1186 ])
1187 .map_or((None, Source::Default), |(value, source)| {
1188 (Some(value), source)
1189 });
1190 let (line_prefix, prefix_source) = answered([
1191 (None, Source::Flag),
1192 (
1193 config.and_then(|c| c.profile.release.line_prefix.clone()),
1194 Source::Config,
1195 ),
1196 (
1197 record.and_then(|r| r.profile.release.line_prefix.clone()),
1198 Source::Record,
1199 ),
1200 (None, Source::Observation),
1201 (
1202 (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1203 Source::Default,
1204 ),
1205 ])
1206 .map_or((None, Source::Default), |(value, source)| {
1207 (Some(value), source)
1208 });
1209
1210 let (registry, registry_source) = answered([
1215 (None, Source::Flag),
1216 (
1217 config.and_then(|c| c.profile.release.registry),
1218 Source::Config,
1219 ),
1220 (
1221 record.and_then(|r| r.profile.release.registry),
1222 Source::Record,
1223 ),
1224 (None, Source::Observation),
1225 (None, Source::Default),
1226 ])
1227 .map_or((None, Source::Default), |(value, source)| {
1228 (Some(value), source)
1229 });
1230
1231 let build_key = |flag: Option<&str>, read: fn(&BuildAnswer) -> Option<String>| {
1235 answered([
1236 (flag.map(str::to_owned), Source::Flag),
1237 (
1238 config.and_then(|c| c.profile.release.build.as_ref().and_then(read)),
1239 Source::Config,
1240 ),
1241 (
1242 record.and_then(|r| r.profile.release.build.as_ref().and_then(read)),
1243 Source::Record,
1244 ),
1245 (None, Source::Observation),
1246 (None, Source::Default),
1247 ])
1248 .map_or((None, Source::Default), |(value, source)| {
1249 (Some(value), source)
1250 })
1251 };
1252 let (build_command, command_source) =
1253 build_key(flags.build_command, |build| build.command.clone());
1254 let (build_packages, packages_source) =
1255 build_key(flags.build_packages, |build| build.packages.clone());
1256 if let Some(command) = &build_command {
1257 crate::config::build_command_shape(command).map_err(crate::config::invalid)?;
1258 }
1259 if let Some(packages) = &build_packages {
1260 crate::config::build_packages_shape(packages).map_err(crate::config::invalid)?;
1261 }
1262 let build = (build_command.is_some() || build_packages.is_some()).then_some(BuildAnswer {
1263 command: build_command,
1264 packages: build_packages,
1265 });
1266
1267 let reporting = purpose == Purpose::Preview;
1272 let release = match mode {
1273 ReleaseMode::Automatic => {
1274 if let Some(Proposal::Ambiguous { drivers }) = &proposal
1275 && driver.is_none()
1276 && !reporting
1277 {
1278 return Err(RkError::Usage(format!(
1279 "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1280 drivers.join(" and "),
1281 crate::config::CONFIG_PATH
1282 )));
1283 }
1284 if forge.is_none() && !reporting {
1285 let message = observed.host.map_or_else(
1286 || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1287 |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1288 );
1289 return Err(RkError::refusal(
1290 Diagnostic::new(Reason::ForgeUndetected, message)
1291 .expected("a github.com or gitlab remote, or --forge")
1292 .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1293 ));
1294 }
1295 if driver.is_none() && !reporting {
1296 return Err(invalid_release(format!(
1297 "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1298 known_drivers.join("|")
1299 )));
1300 }
1301 if let Some(driver) = &driver
1302 && !technologies.contains(driver)
1303 && !reporting
1304 {
1305 return Err(invalid_release(format!(
1306 "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1307 if technologies.is_empty() {
1308 "empty".to_owned()
1309 } else {
1310 technologies.join(", ")
1311 }
1312 )));
1313 }
1314 let style = match (style, purpose) {
1315 (None, Purpose::Upgrade | Purpose::Adopt) => {
1316 return Err(RkError::Usage(
1317 "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1318 ));
1319 }
1320 (None, _) => Style::Trunk,
1321 (Some(style), _) => style,
1322 };
1323 if let Some(answer) = registry
1326 && (driver.is_some() || !reporting)
1327 {
1328 catalog::registry_answer_fits(mode, driver.as_deref(), answer)
1329 .map_err(RkError::Usage)?;
1330 }
1331 if build.is_some() && (driver.is_some() || !reporting) {
1332 catalog::build_answer_fits(mode, driver.as_deref()).map_err(RkError::Usage)?;
1333 }
1334 sources.insert("profile.release.driver", driver_source);
1335 sources.insert("profile.release.style", style_source);
1336 sources.insert("profile.release.line_prefix", prefix_source);
1337 if driver
1338 .as_deref()
1339 .and_then(RegistryAnswer::default_for)
1340 .is_some()
1341 {
1342 sources.insert("profile.release.registry", registry_source);
1343 }
1344 if driver.as_deref() == Some(BuildAnswer::DRIVER) {
1345 sources.insert("profile.release.build.command", command_source);
1346 sources.insert("profile.release.build.packages", packages_source);
1347 }
1348 ReleaseIntent {
1349 mode,
1350 driver,
1351 style: Some(style),
1352 line_prefix: Some(
1353 line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1354 ),
1355 registry,
1356 build,
1357 }
1358 }
1359 ReleaseMode::External | ReleaseMode::None => {
1360 for (key, present, value_source) in [
1367 ("profile.release.driver", driver.is_some(), driver_source),
1368 ("profile.release.style", style.is_some(), style_source),
1369 (
1370 "profile.release.line_prefix",
1371 line_prefix.is_some(),
1372 prefix_source,
1373 ),
1374 (
1375 "profile.release.registry",
1376 registry.is_some(),
1377 registry_source,
1378 ),
1379 (
1380 "profile.release.build.command",
1381 build.as_ref().is_some_and(|build| build.command.is_some()),
1382 command_source,
1383 ),
1384 (
1385 "profile.release.build.packages",
1386 build.as_ref().is_some_and(|build| build.packages.is_some()),
1387 packages_source,
1388 ),
1389 ] {
1390 if present
1391 && matches!(value_source, Source::Flag | Source::Config)
1392 && value_source.rank() <= mode_source.rank()
1393 {
1394 return Err(invalid_release(format!(
1395 "{key} is set while profile.release.mode is {}",
1396 mode.as_str()
1397 )));
1398 }
1399 }
1400 ReleaseIntent {
1401 mode,
1402 driver: None,
1403 style: None,
1404 line_prefix: None,
1405 registry: None,
1406 build: None,
1407 }
1408 }
1409 };
1410
1411 let adapter_known = forge
1415 .as_deref()
1416 .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1417 let repo = match (forge.is_some(), adapter_known, repo) {
1418 (false, _, _) => String::new(),
1421 (true, false, repo) => repo.unwrap_or_default(),
1422 (true, true, Some(repo)) => repo,
1423 (true, true, None) if purpose == Purpose::Preview => {
1424 crate::projection::REPO_PLACEHOLDER.to_owned()
1425 }
1426 (true, true, None) => return Err(crate::landing::repo_unresolved()),
1427 };
1428
1429 let (trunk, source) = answered([
1431 (flags.trunk.map(str::to_owned), Source::Flag),
1432 (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1433 (record.map(|r| r.git.trunk.clone()), Source::Record),
1434 (None, Source::Observation),
1435 (
1436 Some(crate::config::TRUNK_DEFAULT.to_owned()),
1437 Source::Default,
1438 ),
1439 ])
1440 .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1441 sources.insert("git.trunk", source);
1442 let (checkout_mode, source) = answered([
1443 (flags.checkout_mode, Source::Flag),
1444 (config.and_then(|c| c.git.checkout_mode), Source::Config),
1445 (record.map(|r| r.git.checkout_mode), Source::Record),
1446 (None, Source::Observation),
1447 (
1448 Some(if purpose == Purpose::Adopt {
1449 CheckoutMode::MainWorktree
1450 } else {
1451 CheckoutMode::LinkedWorktree
1452 }),
1453 Source::Default,
1454 ),
1455 ])
1456 .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1457 sources.insert("git.checkout_mode", source);
1458 let (integration, source) = answered([
1463 (flags.integration, Source::Flag),
1464 (config.and_then(|c| c.git.integration), Source::Config),
1465 (record.map(|r| r.git.integration), Source::Record),
1466 (None, Source::Observation),
1467 (
1472 Some(if purpose == Purpose::Adopt {
1473 Integration::Forge
1474 } else {
1475 Integration::Local
1476 }),
1477 Source::Default,
1478 ),
1479 ])
1480 .unwrap_or((Integration::Local, Source::Default));
1481 sources.insert("git.integration", source);
1482 let (merge_form, source) = answered([
1486 (flags.merge_form, Source::Flag),
1487 (config.and_then(|c| c.git.merge_form), Source::Config),
1488 (record.map(|r| r.git.merge_form), Source::Record),
1489 (None, Source::Observation),
1490 (Some(manifest::merge_form_squash()), Source::Default),
1491 ])
1492 .unwrap_or((MergeForm::Squash, Source::Default));
1493 sources.insert("git.merge_form", source);
1494
1495 let (nix_packaging, source) = answered([
1497 (flags.nix, Source::Flag),
1498 (
1499 config.and_then(|c| c.capabilities.nix_packaging),
1500 Source::Config,
1501 ),
1502 (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1503 (None, Source::Observation),
1504 (Some(false), Source::Default),
1505 ])
1506 .unwrap_or((false, Source::Default));
1507 sources.insert("capabilities.nix_packaging", source);
1508 let (reporting_policy, source) = answered([
1509 (flags.reporting_policy, Source::Flag),
1510 (
1511 config.and_then(|c| c.capabilities.reporting_policy),
1512 Source::Config,
1513 ),
1514 (
1515 record.map(|r| r.capabilities.reporting_policy),
1516 Source::Record,
1517 ),
1518 (None, Source::Observation),
1519 (
1522 Some(release.mode == ReleaseMode::Automatic),
1523 Source::Default,
1524 ),
1525 ])
1526 .unwrap_or((false, Source::Default));
1527 sources.insert("capabilities.reporting_policy", source);
1528 let (scorecard, source) = answered([
1529 (flags.scorecard, Source::Flag),
1530 (
1531 config.and_then(|c| c.capabilities.scorecard),
1532 Source::Config,
1533 ),
1534 (record.map(|r| r.capabilities.scorecard), Source::Record),
1535 (None, Source::Observation),
1536 (Some(false), Source::Default),
1537 ])
1538 .unwrap_or((false, Source::Default));
1539 sources.insert("capabilities.scorecard", source);
1540 let configured_scanning = config
1541 .and_then(|c| c.capabilities.code_scanning.as_deref())
1542 .map(Provider::parse)
1543 .transpose()?;
1544 let (code_scanning, source) = answered([
1545 (flags.code_scanning, Source::Flag),
1546 (configured_scanning, Source::Config),
1547 (record.map(|r| r.capabilities.code_scanning), Source::Record),
1548 (None, Source::Observation),
1549 (Some(None), Source::Default),
1550 ])
1551 .unwrap_or((None, Source::Default));
1552 sources.insert("capabilities.code_scanning", source);
1553 let (workflow_guards, source) = answered([
1558 (flags.workflow_guards, Source::Flag),
1559 (
1560 config.and_then(|c| c.capabilities.workflow_guards),
1561 Source::Config,
1562 ),
1563 (
1564 record.map(|r| r.capabilities.workflow_guards()),
1565 Source::Record,
1566 ),
1567 (None, Source::Observation),
1568 (Some(false), Source::Default),
1569 ])
1570 .unwrap_or((false, Source::Default));
1571 sources.insert("capabilities.workflow_guards", source);
1572 let (security_contact, source) = answered([
1580 (None, Source::Flag),
1581 (
1582 config.and_then(|c| c.security.contact.clone()),
1583 Source::Config,
1584 ),
1585 (
1586 record.map(|r| r.parameters.security_contact.clone()),
1587 Source::Record,
1588 ),
1589 (None, Source::Observation),
1590 (Some(String::new()), Source::Default),
1591 ])
1592 .unwrap_or((String::new(), Source::Default));
1593 let security_contact =
1594 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1595 sources.insert("security.contact", source);
1596 let (security_response, source) = answered([
1597 (None, Source::Flag),
1598 (
1599 config.and_then(|c| c.security.response.clone()),
1600 Source::Config,
1601 ),
1602 (
1603 record.map(|r| r.parameters.security_response.clone()),
1604 Source::Record,
1605 ),
1606 (None, Source::Observation),
1607 (
1608 Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1609 Source::Default,
1610 ),
1611 ])
1612 .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1613 let security_response =
1614 crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1615 sources.insert("security.response", source);
1616 let (security_scope, source) = answered([
1617 (None, Source::Flag),
1618 (
1619 config.and_then(|c| c.security.scope.clone()),
1620 Source::Config,
1621 ),
1622 (
1623 record.map(|r| r.parameters.security_scope.clone()),
1624 Source::Record,
1625 ),
1626 (None, Source::Observation),
1627 (Some(String::new()), Source::Default),
1628 ])
1629 .unwrap_or((String::new(), Source::Default));
1630 let security_scope =
1631 crate::config::canonical_scope(&security_scope).map_err(crate::config::invalid)?;
1632 sources.insert("security.scope", source);
1633
1634 let gate_default = gate_defaults(
1639 &ProfileSnapshot {
1640 technologies: technologies.clone(),
1641 forge: forge.clone(),
1642 release: release.clone(),
1643 },
1644 &GitWorkflow {
1645 trunk: trunk.clone(),
1646 checkout_mode,
1647 integration,
1648 merge_form,
1649 },
1650 );
1651 let (required_check, source) = answered([
1652 (flags.required_check.map(str::to_owned), Source::Flag),
1653 (
1654 config
1655 .map(|c| c.setup.required_check.clone())
1656 .filter(|name| !name.is_empty()),
1657 Source::Config,
1658 ),
1659 (
1660 record
1661 .map(|r| r.parameters.required_check.clone())
1662 .filter(|name| !name.is_empty()),
1663 Source::Record,
1664 ),
1665 (None, Source::Observation),
1666 (
1667 Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1668 Source::Default,
1669 ),
1670 ])
1671 .unwrap_or((String::new(), Source::Default));
1672 sources.insert("setup.required_check", source);
1673 let (required_workflow, source) = answered([
1674 (flags.required_workflow.map(str::to_owned), Source::Flag),
1675 (
1676 config
1677 .map(|c| c.setup.required_workflow.clone())
1678 .filter(|name| !name.is_empty()),
1679 Source::Config,
1680 ),
1681 (
1682 record
1683 .map(|r| r.parameters.required_workflow.clone())
1684 .filter(|name| !name.is_empty()),
1685 Source::Record,
1686 ),
1687 (None, Source::Observation),
1688 (
1689 Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1690 Source::Default,
1691 ),
1692 ])
1693 .unwrap_or((String::new(), Source::Default));
1694 sources.insert("setup.required_workflow", source);
1695 let recorded = || {
1699 record
1700 .and_then(|r| r.parameters.dev_environment.clone())
1701 .map_or((None, Source::Default), |answer| {
1702 (Some(answer), Source::Record)
1703 })
1704 };
1705 let (dev_environment, source) = config
1706 .and_then(|c| c.dev_environment.clone())
1707 .map_or_else(recorded, |answer| (answer.answered(), Source::Config));
1708 if let Some(answer) = &dev_environment {
1709 answer
1710 .check_forge(forge.as_deref())
1711 .map_err(crate::config::invalid)?;
1712 }
1713 sources.insert("dev_environment", source);
1714 if forge.as_deref() == Some("gitlab") {
1719 for (flag, supplied) in [
1720 ("--required-check", flags.required_check),
1721 ("--required-workflow", flags.required_workflow),
1722 ] {
1723 if supplied.is_some() {
1724 return Err(RkError::Usage(format!(
1725 "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1726 )));
1727 }
1728 }
1729 }
1730
1731 let mut unknown: Vec<String> = technologies
1732 .iter()
1733 .filter(|name| !known_drivers.contains(name))
1734 .map(|name| format!("technology {name}"))
1735 .collect();
1736 if let Some(name) = &forge
1737 && crate::detect::Forge::parse(name).is_none()
1738 {
1739 unknown.push(format!("forge {name}"));
1740 }
1741
1742 let (bot_name, source) = config
1745 .map(|c| c.setup.bot.name.trim().to_owned())
1746 .filter(|name| !name.is_empty())
1747 .map_or((String::new(), Source::Default), |name| {
1748 (name, Source::Config)
1749 });
1750 sources.insert("setup.bot.name", source);
1751
1752 Ok(Resolved {
1753 params: Params {
1754 profile: ProfileSnapshot {
1755 technologies,
1756 forge,
1757 release,
1758 },
1759 git: GitWorkflow {
1760 trunk,
1761 checkout_mode,
1762 integration,
1763 merge_form,
1764 },
1765 capabilities: CapabilityRequests {
1766 nix_packaging,
1767 reporting_policy,
1768 scorecard,
1769 code_scanning,
1770 workflow_guards: Some(workflow_guards),
1771 },
1772 repo,
1773 security_contact,
1774 security_response,
1775 security_scope,
1776 required_check,
1777 required_workflow,
1778 dev_environment,
1779 },
1780 sources,
1781 unknown,
1782 proposal,
1783 bot_name,
1784 })
1785}