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, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ReleaseIntent {
109 pub mode: ReleaseMode,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub driver: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub style: Option<Style>,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub line_prefix: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub registry: Option<RegistryAnswer>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ProfileSnapshot {
132 pub technologies: Vec<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub forge: Option<String>,
137 pub release: ReleaseIntent,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct GitWorkflow {
144 pub trunk: String,
146 pub checkout_mode: CheckoutMode,
148 #[serde(default = "crate::landing::manifest::integration_forge")]
151 pub integration: Integration,
152 #[serde(default = "crate::landing::manifest::merge_form_squash")]
155 pub merge_form: MergeForm,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct CapabilityRequests {
161 #[serde(default)]
163 pub nix_packaging: bool,
164 #[serde(default)]
166 pub reporting_policy: bool,
167 #[serde(default)]
169 pub scorecard: bool,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub code_scanning: Option<Provider>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub workflow_guards: Option<bool>,
179}
180
181impl CapabilityRequests {
182 #[must_use]
186 pub fn workflow_guards(&self) -> bool {
187 self.workflow_guards.unwrap_or(true)
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
193#[serde(rename_all = "lowercase")]
194pub enum Source {
195 Flag,
197 Config,
199 Record,
201 Observation,
203 Default,
205}
206
207impl Source {
208 #[must_use]
214 pub const fn rank(self) -> u8 {
215 match self {
216 Self::Flag => 0,
217 Self::Config => 1,
218 Self::Record => 2,
219 Self::Observation => 3,
220 Self::Default => 4,
221 }
222 }
223
224 #[must_use]
226 pub const fn as_str(self) -> &'static str {
227 match self {
228 Self::Flag => "flag",
229 Self::Config => "config",
230 Self::Record => "record",
231 Self::Observation => "observation",
232 Self::Default => "default",
233 }
234 }
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
240#[serde(rename_all = "kebab-case", tag = "state")]
241pub enum Proposal {
242 None,
244 Automatic {
246 driver: String,
248 },
249 Ambiguous {
252 drivers: Vec<String>,
254 },
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct Params {
263 profile: ProfileSnapshot,
264 git: GitWorkflow,
265 capabilities: CapabilityRequests,
266 repo: String,
267 security_contact: String,
268 security_response: String,
269 required_check: String,
270 required_workflow: String,
271 dev_environment: Option<DevEnvironment>,
272}
273
274#[derive(Default)]
276pub struct Inputs<'a> {
277 pub technologies: &'a [String],
280 pub forge: Option<&'a str>,
282 pub repo: Option<&'a str>,
284 pub release_mode: Option<ReleaseMode>,
286 pub release_driver: Option<&'a str>,
288 pub style: Option<Style>,
290 pub trunk: Option<&'a str>,
292 pub checkout_mode: Option<CheckoutMode>,
294 pub integration: Option<Integration>,
296 pub merge_form: Option<MergeForm>,
298 pub required_check: Option<&'a str>,
300 pub required_workflow: Option<&'a str>,
303 pub nix: Option<bool>,
305 pub reporting_policy: Option<bool>,
307 pub scorecard: Option<bool>,
309 pub code_scanning: Option<Option<Provider>>,
312 pub workflow_guards: Option<bool>,
314}
315
316#[derive(Clone, Copy, PartialEq, Eq)]
318pub enum Purpose {
319 Init,
321 Preview,
324 Upgrade,
326 Adopt,
328}
329
330#[derive(Debug, Clone)]
332pub struct Resolved {
333 pub params: Params,
335 pub sources: BTreeMap<&'static str, Source>,
337 pub unknown: Vec<String>,
339 pub proposal: Option<Proposal>,
342}
343
344pub fn canonical_category(raw: &str) -> Result<String, String> {
354 let lowered = raw.trim().to_ascii_lowercase();
355 let shaped = lowered
356 .chars()
357 .next()
358 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
359 && lowered
360 .chars()
361 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
362 if shaped {
363 Ok(lowered)
364 } else {
365 Err(format!(
366 "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
367 ))
368 }
369}
370
371pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
377 let mut out = Vec::with_capacity(raw.len());
378 for value in raw {
379 let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
380 if out.contains(&name) {
381 return Err(format!("{key} names {name} twice; each technology once"));
382 }
383 out.push(name);
384 }
385 out.sort();
386 Ok(out)
387}
388
389fn push_shell_word(out: &mut String, value: &str) {
392 let inert = !value.is_empty()
393 && value.bytes().all(|byte| {
394 byte.is_ascii_alphanumeric()
395 || matches!(
396 byte,
397 b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
398 )
399 });
400 if inert {
401 out.push_str(value);
402 return;
403 }
404 out.push('\'');
405 out.push_str(&value.replace('\'', "'\"'\"'"));
406 out.push('\'');
407}
408
409impl Params {
410 #[must_use]
413 pub fn from_record(record: &manifest::Manifest) -> Self {
414 Self {
415 profile: record.profile.clone(),
416 git: record.git.clone(),
417 capabilities: record.capabilities.clone(),
418 repo: record.parameters.repo.clone(),
419 security_contact: record.parameters.security_contact.clone(),
420 security_response: record.parameters.security_response.clone(),
421 required_check: gate_answer(
424 record,
425 |(check, _)| check,
426 |parameters| ¶meters.required_check,
427 ),
428 required_workflow: gate_answer(
429 record,
430 |(_, workflow)| workflow,
431 |parameters| ¶meters.required_workflow,
432 ),
433 dev_environment: record.parameters.dev_environment.clone(),
434 }
435 }
436
437 pub fn resolve(
445 target: &Utf8Path,
446 flags: &Inputs<'_>,
447 config: Option<&crate::config::Config>,
448 record: Option<&manifest::Manifest>,
449 purpose: Purpose,
450 ) -> Result<Self, RkError> {
451 resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
452 }
453
454 #[must_use]
456 pub const fn profile(&self) -> &ProfileSnapshot {
457 &self.profile
458 }
459
460 #[must_use]
462 pub const fn git(&self) -> &GitWorkflow {
463 &self.git
464 }
465
466 #[must_use]
468 pub const fn capabilities(&self) -> &CapabilityRequests {
469 &self.capabilities
470 }
471
472 #[must_use]
474 pub fn technologies(&self) -> &[String] {
475 &self.profile.technologies
476 }
477
478 #[must_use]
480 pub fn forge(&self) -> Option<&str> {
481 self.profile.forge.as_deref()
482 }
483
484 #[must_use]
486 pub const fn release_mode(&self) -> ReleaseMode {
487 self.profile.release.mode
488 }
489
490 #[must_use]
492 pub fn driver(&self) -> Option<&str> {
493 self.profile.release.driver.as_deref()
494 }
495
496 #[must_use]
498 pub const fn style(&self) -> Option<Style> {
499 self.profile.release.style
500 }
501
502 #[must_use]
505 pub fn registry(&self) -> Option<RegistryAnswer> {
506 (self.release_mode() == ReleaseMode::Automatic
507 && self.driver() == Some(RegistryAnswer::DRIVER))
508 .then(|| {
509 self.profile
510 .release
511 .registry
512 .unwrap_or(RegistryAnswer::CratesIo)
513 })
514 }
515
516 #[must_use]
518 pub const fn nix_packaging(&self) -> bool {
519 self.capabilities.nix_packaging
520 }
521
522 #[must_use]
524 pub const fn reporting_policy(&self) -> bool {
525 self.capabilities.reporting_policy
526 }
527
528 #[must_use]
530 pub const fn scorecard(&self) -> bool {
531 self.capabilities.scorecard
532 }
533
534 #[must_use]
536 pub const fn code_scanning(&self) -> Option<Provider> {
537 self.capabilities.code_scanning
538 }
539
540 #[must_use]
542 pub fn workflow_guards(&self) -> bool {
543 self.capabilities.workflow_guards()
544 }
545
546 #[must_use]
549 pub fn repo(&self) -> &str {
550 &self.repo
551 }
552
553 #[must_use]
555 pub const fn checkout_mode(&self) -> CheckoutMode {
556 self.git.checkout_mode
557 }
558
559 #[must_use]
561 pub const fn integration(&self) -> Integration {
562 self.git.integration
563 }
564
565 #[must_use]
567 pub const fn merge_form(&self) -> MergeForm {
568 self.git.merge_form
569 }
570
571 #[must_use]
573 pub fn trunk(&self) -> &str {
574 &self.git.trunk
575 }
576
577 #[must_use]
580 pub fn line_prefix(&self) -> &str {
581 self.profile
582 .release
583 .line_prefix
584 .as_deref()
585 .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
586 }
587
588 #[must_use]
591 pub fn security_contact(&self) -> &str {
592 &self.security_contact
593 }
594
595 #[must_use]
597 pub fn security_response(&self) -> &str {
598 &self.security_response
599 }
600
601 #[must_use]
604 pub fn required_check(&self) -> &str {
605 &self.required_check
606 }
607
608 #[must_use]
611 pub const fn dev_environment(&self) -> Option<&DevEnvironment> {
612 self.dev_environment.as_ref()
613 }
614
615 #[must_use]
618 pub fn required_workflow(&self) -> &str {
619 &self.required_workflow
620 }
621
622 #[must_use]
626 pub fn canonical_flags(&self) -> String {
627 let mut out = String::new();
628 for technology in &self.profile.technologies {
629 out.push_str(" --technology ");
630 out.push_str(technology);
631 }
632 if let Some(forge) = &self.profile.forge {
633 out.push_str(" --forge ");
634 out.push_str(forge);
635 }
636 if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
640 out.push_str(" --repo ");
641 out.push_str(&self.repo);
642 }
643 out.push_str(" --release-mode ");
644 out.push_str(self.profile.release.mode.as_str());
645 if let Some(driver) = &self.profile.release.driver {
646 out.push_str(" --release-driver ");
647 out.push_str(driver);
648 }
649 if let Some(style) = self.profile.release.style {
650 out.push_str(" --release-style ");
651 out.push_str(style.as_str());
652 }
653 out.push_str(" --trunk ");
654 out.push_str(&self.git.trunk);
655 out.push_str(" --checkout-mode ");
656 out.push_str(self.git.checkout_mode.as_str());
657 out.push_str(" --integration ");
658 out.push_str(self.git.integration.as_str());
659 out.push_str(" --merge-form ");
660 out.push_str(self.git.merge_form.as_str());
661 if !self.required_check.is_empty() {
665 out.push_str(" --required-check ");
666 push_shell_word(&mut out, &self.required_check);
667 }
668 if !self.required_workflow.is_empty() {
669 out.push_str(" --required-workflow ");
670 push_shell_word(&mut out, &self.required_workflow);
671 }
672 out
673 }
674
675 #[must_use]
681 pub fn capability_flags(&self) -> String {
682 let mut out = String::new();
683 if self.capabilities.nix_packaging {
684 out.push_str(" --nix-packaging");
685 }
686 if self.capabilities.reporting_policy {
687 out.push_str(" --reporting-policy");
688 }
689 if self.capabilities.scorecard {
690 out.push_str(" --scorecard");
691 }
692 if self.workflow_guards() {
693 out.push_str(" --workflow-guards");
694 }
695 out.push_str(" --code-scanning ");
702 out.push_str(
703 self.capabilities
704 .code_scanning
705 .map_or("off", Provider::as_str),
706 );
707 out
708 }
709
710 #[must_use]
717 pub fn capability_toggles(&self) -> String {
718 let word = |on: bool| if on { "on" } else { "off" };
719 format!(
720 " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {} --workflow-guards {}",
721 word(self.capabilities.nix_packaging),
722 word(self.capabilities.reporting_policy),
723 word(self.capabilities.scorecard),
724 self.capabilities
725 .code_scanning
726 .map_or("off", Provider::as_str),
727 word(self.workflow_guards())
728 )
729 }
730}
731
732#[cfg(test)]
733impl Params {
734 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
739 Self {
740 profile: ProfileSnapshot {
741 technologies: vec!["rust".to_owned()],
742 forge: Some("github".to_owned()),
743 release: ReleaseIntent {
744 mode: ReleaseMode::Automatic,
745 driver: Some("rust".to_owned()),
746 style,
747 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
748 registry: None,
749 },
750 },
751 git: GitWorkflow {
752 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
753 checkout_mode: CheckoutMode::LinkedWorktree,
754 integration: Integration::Local,
755 merge_form: MergeForm::Squash,
756 },
757 capabilities: CapabilityRequests {
758 nix_packaging: false,
759 reporting_policy: true,
760 scorecard: false,
761 code_scanning: None,
762 workflow_guards: Some(true),
763 },
764 repo: repo.to_owned(),
765 security_contact: String::new(),
766 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
767 required_check: "gate".to_owned(),
768 required_workflow: "ci".to_owned(),
769 dev_environment: None,
770 }
771 }
772
773 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
775 Self {
776 security_contact: contact.to_owned(),
777 security_response: response.to_owned(),
778 ..Self::for_test("acme/widget", Some(Style::Trunk))
779 }
780 }
781
782 pub(crate) fn for_test_release_less(
785 technologies: &[&str],
786 forge: Option<&str>,
787 mode: ReleaseMode,
788 ) -> Self {
789 let mut params = Self::for_test("acme/widget", None);
790 params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
791 params.profile.forge = forge.map(str::to_owned);
792 params.profile.release = ReleaseIntent {
793 mode,
794 driver: None,
795 style: None,
796 line_prefix: None,
797 registry: None,
798 };
799 params.capabilities.reporting_policy = false;
800 if forge.is_none() {
801 params.repo = String::new();
802 }
803 params
804 }
805
806 pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
808 self.profile.technologies = vec![driver.to_owned()];
809 self.profile.release.driver = Some(driver.to_owned());
810 self.profile.forge = Some(forge.to_owned());
811 }
812
813 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
815 self.git.checkout_mode = mode;
816 }
817
818 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
820 self.git.integration = mode;
821 }
822
823 pub(crate) const fn set_merge_form_for_test(&mut self, form: MergeForm) {
825 self.git.merge_form = form;
826 }
827
828 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
830 self.capabilities.nix_packaging = nix;
831 }
832
833 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
835 self.capabilities.scorecard = scorecard;
836 }
837
838 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
840 self.capabilities.code_scanning = provider;
841 }
842
843 pub(crate) const fn set_workflow_guards_for_test(&mut self, on: bool) {
845 self.capabilities.workflow_guards = Some(on);
846 }
847}
848
849fn invalid_release(message: impl std::fmt::Display) -> RkError {
852 RkError::Usage(format!(
853 "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
854 ))
855}
856
857#[must_use]
879pub fn gate_defaults(
880 profile: &ProfileSnapshot,
881 git: &GitWorkflow,
882) -> Option<(&'static str, &'static str)> {
883 let consuming = profile.forge.as_deref() == Some("github")
884 && profile.release.mode == ReleaseMode::Automatic
885 && profile.release.style == Some(Style::Trunk)
886 && git.integration == Integration::Local;
887 consuming.then_some(("gate", "ci"))
888}
889
890fn gate_answer(
893 record: &manifest::Manifest,
894 pick: impl Fn((&'static str, &'static str)) -> &'static str,
895 held: impl Fn(&manifest::Parameters) -> &String,
896) -> String {
897 let recorded = held(&record.parameters);
898 if recorded.is_empty() {
899 gate_defaults(&record.profile, &record.git)
900 .map_or_else(String::new, |pair| pick(pair).to_owned())
901 } else {
902 recorded.clone()
903 }
904}
905
906fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
908 chain
909 .into_iter()
910 .find_map(|(value, source)| value.map(|value| (value, source)))
911}
912
913#[allow(
921 clippy::too_many_lines,
922 reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
923)]
924pub fn resolve(
925 target: &Utf8Path,
926 flags: &Inputs<'_>,
927 config: Option<&crate::config::Config>,
928 record: Option<&manifest::Manifest>,
929 purpose: Purpose,
930) -> Result<Resolved, RkError> {
931 let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
932 let observed = crate::detect::observe(target.as_std_path());
933 let known_drivers = catalog::known_drivers();
934
935 let (technologies, source) = answered([
937 (
938 (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
939 Source::Flag,
940 ),
941 (
942 config.and_then(|c| c.profile.technologies.clone()),
943 Source::Config,
944 ),
945 (
946 record.map(|r| r.profile.technologies.clone()),
947 Source::Record,
948 ),
949 (
950 Some(
951 observed
952 .technologies
953 .iter()
954 .map(|t| (*t).to_owned())
955 .collect(),
956 ),
957 Source::Observation,
958 ),
959 (None, Source::Default),
960 ])
961 .unwrap_or_else(|| (Vec::new(), Source::Default));
962 let technologies =
963 canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
964 sources.insert("profile.technologies", source);
965
966 let forge_flag = flags
968 .forge
969 .map(|name| canonical_category(name).map_err(RkError::Usage))
970 .transpose()?;
971 let (forge, source) = answered([
972 (forge_flag.map(Some), Source::Flag),
973 (
974 config
975 .and_then(|c| c.profile.forge.clone())
976 .map(|value| if value.is_empty() { None } else { Some(value) }),
977 Source::Config,
978 ),
979 (record.map(|r| r.profile.forge.clone()), Source::Record),
980 (
981 observed.forge.map(|forge| Some(forge.as_str().to_owned())),
982 Source::Observation,
983 ),
984 (Some(None), Source::Default),
985 ])
986 .unwrap_or((None, Source::Default));
987 let forge = forge
988 .map(|name| canonical_category(&name).map_err(RkError::Usage))
989 .transpose()?;
990 sources.insert("profile.forge", source);
991
992 let (repo, source) = answered([
994 (flags.repo.map(str::to_owned), Source::Flag),
995 (
996 config
997 .map(|c| c.project.repo.clone())
998 .filter(|value| !value.is_empty()),
999 Source::Config,
1000 ),
1001 (
1002 record
1003 .map(|r| r.parameters.repo.clone())
1004 .filter(|value| !value.is_empty()),
1005 Source::Record,
1006 ),
1007 (observed.repo.clone(), Source::Observation),
1008 (None, Source::Default),
1009 ])
1010 .map_or((None, Source::Default), |(value, source)| {
1011 (Some(value), source)
1012 });
1013 sources.insert("project.repo", source);
1014
1015 let release_bearing: Vec<String> = technologies
1018 .iter()
1019 .filter(|name| known_drivers.contains(name))
1020 .cloned()
1021 .collect();
1022 let proposal = match release_bearing.as_slice() {
1023 [] => Proposal::None,
1024 [one] => Proposal::Automatic {
1025 driver: one.clone(),
1026 },
1027 many => Proposal::Ambiguous {
1028 drivers: many.to_vec(),
1029 },
1030 };
1031 let proposed_mode = match &proposal {
1038 Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
1039 Proposal::None => ReleaseMode::None,
1040 };
1041 let (mode, source) = answered([
1042 (flags.release_mode, Source::Flag),
1043 (config.and_then(|c| c.profile.release.mode), Source::Config),
1044 (record.map(|r| r.profile.release.mode), Source::Record),
1045 (Some(proposed_mode), Source::Observation),
1046 (None, Source::Default),
1047 ])
1048 .unwrap_or((ReleaseMode::None, Source::Default));
1049 let mode_source = source;
1050 sources.insert("profile.release.mode", mode_source);
1051 let mode_answered_above = mode_source != Source::Observation;
1052 let proposal = (!mode_answered_above).then_some(proposal);
1053
1054 let driver_flag = flags
1058 .release_driver
1059 .map(|name| canonical_category(name).map_err(RkError::Usage))
1060 .transpose()?;
1061 let (driver, driver_source) = answered([
1062 (driver_flag, Source::Flag),
1063 (
1064 config.and_then(|c| c.profile.release.driver.clone()),
1065 Source::Config,
1066 ),
1067 (
1068 record.and_then(|r| r.profile.release.driver.clone()),
1069 Source::Record,
1070 ),
1071 (
1072 match &proposal {
1073 Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
1074 Some(driver.clone())
1075 }
1076 _ => None,
1077 },
1078 Source::Observation,
1079 ),
1080 (None, Source::Default),
1081 ])
1082 .map_or((None, Source::Default), |(value, source)| {
1083 (Some(value), source)
1084 });
1085 let (style, style_source) = answered([
1086 (flags.style, Source::Flag),
1087 (config.and_then(|c| c.profile.release.style), Source::Config),
1088 (record.and_then(|r| r.profile.release.style), Source::Record),
1089 (None, Source::Observation),
1090 (
1091 (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
1092 .then_some(Style::Trunk),
1093 Source::Default,
1094 ),
1095 ])
1096 .map_or((None, Source::Default), |(value, source)| {
1097 (Some(value), source)
1098 });
1099 let (line_prefix, prefix_source) = answered([
1100 (None, Source::Flag),
1101 (
1102 config.and_then(|c| c.profile.release.line_prefix.clone()),
1103 Source::Config,
1104 ),
1105 (
1106 record.and_then(|r| r.profile.release.line_prefix.clone()),
1107 Source::Record,
1108 ),
1109 (None, Source::Observation),
1110 (
1111 (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1112 Source::Default,
1113 ),
1114 ])
1115 .map_or((None, Source::Default), |(value, source)| {
1116 (Some(value), source)
1117 });
1118
1119 let (registry, registry_source) = answered([
1123 (None, Source::Flag),
1124 (
1125 config.and_then(|c| c.profile.release.registry),
1126 Source::Config,
1127 ),
1128 (
1129 record.and_then(|r| r.profile.release.registry),
1130 Source::Record,
1131 ),
1132 (None, Source::Observation),
1133 (None, Source::Default),
1134 ])
1135 .map_or((None, Source::Default), |(value, source)| {
1136 (Some(value), source)
1137 });
1138
1139 let reporting = purpose == Purpose::Preview;
1144 let release = match mode {
1145 ReleaseMode::Automatic => {
1146 if let Some(Proposal::Ambiguous { drivers }) = &proposal
1147 && driver.is_none()
1148 && !reporting
1149 {
1150 return Err(RkError::Usage(format!(
1151 "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1152 drivers.join(" and "),
1153 crate::config::CONFIG_PATH
1154 )));
1155 }
1156 if forge.is_none() && !reporting {
1157 let message = observed.host.map_or_else(
1158 || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1159 |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1160 );
1161 return Err(RkError::refusal(
1162 Diagnostic::new(Reason::ForgeUndetected, message)
1163 .expected("a github.com or gitlab remote, or --forge")
1164 .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1165 ));
1166 }
1167 if driver.is_none() && !reporting {
1168 return Err(invalid_release(format!(
1169 "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1170 known_drivers.join("|")
1171 )));
1172 }
1173 if let Some(driver) = &driver
1174 && !technologies.contains(driver)
1175 && !reporting
1176 {
1177 return Err(invalid_release(format!(
1178 "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1179 if technologies.is_empty() {
1180 "empty".to_owned()
1181 } else {
1182 technologies.join(", ")
1183 }
1184 )));
1185 }
1186 let style = match (style, purpose) {
1187 (None, Purpose::Upgrade | Purpose::Adopt) => {
1188 return Err(RkError::Usage(
1189 "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1190 ));
1191 }
1192 (None, _) => Style::Trunk,
1193 (Some(style), _) => style,
1194 };
1195 if registry.is_some() && (driver.is_some() || !reporting) {
1198 catalog::registry_answer_fits(mode, driver.as_deref()).map_err(RkError::Usage)?;
1199 }
1200 sources.insert("profile.release.driver", driver_source);
1201 sources.insert("profile.release.style", style_source);
1202 sources.insert("profile.release.line_prefix", prefix_source);
1203 if driver.as_deref() == Some(RegistryAnswer::DRIVER) {
1204 sources.insert("profile.release.registry", registry_source);
1205 }
1206 ReleaseIntent {
1207 mode,
1208 driver,
1209 style: Some(style),
1210 line_prefix: Some(
1211 line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1212 ),
1213 registry,
1214 }
1215 }
1216 ReleaseMode::External | ReleaseMode::None => {
1217 for (key, present, value_source) in [
1224 ("profile.release.driver", driver.is_some(), driver_source),
1225 ("profile.release.style", style.is_some(), style_source),
1226 (
1227 "profile.release.line_prefix",
1228 line_prefix.is_some(),
1229 prefix_source,
1230 ),
1231 (
1232 "profile.release.registry",
1233 registry.is_some(),
1234 registry_source,
1235 ),
1236 ] {
1237 if present
1238 && matches!(value_source, Source::Flag | Source::Config)
1239 && value_source.rank() <= mode_source.rank()
1240 {
1241 return Err(invalid_release(format!(
1242 "{key} is set while profile.release.mode is {}",
1243 mode.as_str()
1244 )));
1245 }
1246 }
1247 ReleaseIntent {
1248 mode,
1249 driver: None,
1250 style: None,
1251 line_prefix: None,
1252 registry: None,
1253 }
1254 }
1255 };
1256
1257 let adapter_known = forge
1261 .as_deref()
1262 .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1263 let repo = match (forge.is_some(), adapter_known, repo) {
1264 (false, _, _) => String::new(),
1267 (true, false, repo) => repo.unwrap_or_default(),
1268 (true, true, Some(repo)) => repo,
1269 (true, true, None) if purpose == Purpose::Preview => {
1270 crate::projection::REPO_PLACEHOLDER.to_owned()
1271 }
1272 (true, true, None) => return Err(crate::landing::repo_unresolved()),
1273 };
1274
1275 let (trunk, source) = answered([
1277 (flags.trunk.map(str::to_owned), Source::Flag),
1278 (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1279 (record.map(|r| r.git.trunk.clone()), Source::Record),
1280 (None, Source::Observation),
1281 (
1282 Some(crate::config::TRUNK_DEFAULT.to_owned()),
1283 Source::Default,
1284 ),
1285 ])
1286 .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1287 sources.insert("git.trunk", source);
1288 let (checkout_mode, source) = answered([
1289 (flags.checkout_mode, Source::Flag),
1290 (config.and_then(|c| c.git.checkout_mode), Source::Config),
1291 (record.map(|r| r.git.checkout_mode), Source::Record),
1292 (None, Source::Observation),
1293 (
1294 Some(if purpose == Purpose::Adopt {
1295 CheckoutMode::MainWorktree
1296 } else {
1297 CheckoutMode::LinkedWorktree
1298 }),
1299 Source::Default,
1300 ),
1301 ])
1302 .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1303 sources.insert("git.checkout_mode", source);
1304 let (integration, source) = answered([
1309 (flags.integration, Source::Flag),
1310 (config.and_then(|c| c.git.integration), Source::Config),
1311 (record.map(|r| r.git.integration), Source::Record),
1312 (None, Source::Observation),
1313 (
1318 Some(if purpose == Purpose::Adopt {
1319 Integration::Forge
1320 } else {
1321 Integration::Local
1322 }),
1323 Source::Default,
1324 ),
1325 ])
1326 .unwrap_or((Integration::Local, Source::Default));
1327 sources.insert("git.integration", source);
1328 let (merge_form, source) = answered([
1332 (flags.merge_form, Source::Flag),
1333 (config.and_then(|c| c.git.merge_form), Source::Config),
1334 (record.map(|r| r.git.merge_form), Source::Record),
1335 (None, Source::Observation),
1336 (Some(manifest::merge_form_squash()), Source::Default),
1337 ])
1338 .unwrap_or((MergeForm::Squash, Source::Default));
1339 sources.insert("git.merge_form", source);
1340
1341 let (nix_packaging, source) = answered([
1343 (flags.nix, Source::Flag),
1344 (
1345 config.and_then(|c| c.capabilities.nix_packaging),
1346 Source::Config,
1347 ),
1348 (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1349 (None, Source::Observation),
1350 (Some(false), Source::Default),
1351 ])
1352 .unwrap_or((false, Source::Default));
1353 sources.insert("capabilities.nix_packaging", source);
1354 let (reporting_policy, source) = answered([
1355 (flags.reporting_policy, Source::Flag),
1356 (
1357 config.and_then(|c| c.capabilities.reporting_policy),
1358 Source::Config,
1359 ),
1360 (
1361 record.map(|r| r.capabilities.reporting_policy),
1362 Source::Record,
1363 ),
1364 (None, Source::Observation),
1365 (
1368 Some(release.mode == ReleaseMode::Automatic),
1369 Source::Default,
1370 ),
1371 ])
1372 .unwrap_or((false, Source::Default));
1373 sources.insert("capabilities.reporting_policy", source);
1374 let (scorecard, source) = answered([
1375 (flags.scorecard, Source::Flag),
1376 (
1377 config.and_then(|c| c.capabilities.scorecard),
1378 Source::Config,
1379 ),
1380 (record.map(|r| r.capabilities.scorecard), Source::Record),
1381 (None, Source::Observation),
1382 (Some(false), Source::Default),
1383 ])
1384 .unwrap_or((false, Source::Default));
1385 sources.insert("capabilities.scorecard", source);
1386 let configured_scanning = config
1387 .and_then(|c| c.capabilities.code_scanning.as_deref())
1388 .map(Provider::parse)
1389 .transpose()?;
1390 let (code_scanning, source) = answered([
1391 (flags.code_scanning, Source::Flag),
1392 (configured_scanning, Source::Config),
1393 (record.map(|r| r.capabilities.code_scanning), Source::Record),
1394 (None, Source::Observation),
1395 (Some(None), Source::Default),
1396 ])
1397 .unwrap_or((None, Source::Default));
1398 sources.insert("capabilities.code_scanning", source);
1399 let (workflow_guards, source) = answered([
1404 (flags.workflow_guards, Source::Flag),
1405 (
1406 config.and_then(|c| c.capabilities.workflow_guards),
1407 Source::Config,
1408 ),
1409 (
1410 record.map(|r| r.capabilities.workflow_guards()),
1411 Source::Record,
1412 ),
1413 (None, Source::Observation),
1414 (Some(false), Source::Default),
1415 ])
1416 .unwrap_or((false, Source::Default));
1417 sources.insert("capabilities.workflow_guards", source);
1418 let (security_contact, source) = answered([
1426 (None, Source::Flag),
1427 (
1428 config.and_then(|c| c.security.contact.clone()),
1429 Source::Config,
1430 ),
1431 (
1432 record.map(|r| r.parameters.security_contact.clone()),
1433 Source::Record,
1434 ),
1435 (None, Source::Observation),
1436 (Some(String::new()), Source::Default),
1437 ])
1438 .unwrap_or((String::new(), Source::Default));
1439 let security_contact =
1440 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1441 sources.insert("security.contact", source);
1442 let (security_response, source) = answered([
1443 (None, Source::Flag),
1444 (
1445 config.and_then(|c| c.security.response.clone()),
1446 Source::Config,
1447 ),
1448 (
1449 record.map(|r| r.parameters.security_response.clone()),
1450 Source::Record,
1451 ),
1452 (None, Source::Observation),
1453 (
1454 Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1455 Source::Default,
1456 ),
1457 ])
1458 .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1459 let security_response =
1460 crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1461 sources.insert("security.response", source);
1462
1463 let gate_default = gate_defaults(
1468 &ProfileSnapshot {
1469 technologies: technologies.clone(),
1470 forge: forge.clone(),
1471 release: release.clone(),
1472 },
1473 &GitWorkflow {
1474 trunk: trunk.clone(),
1475 checkout_mode,
1476 integration,
1477 merge_form,
1478 },
1479 );
1480 let (required_check, source) = answered([
1481 (flags.required_check.map(str::to_owned), Source::Flag),
1482 (
1483 config
1484 .map(|c| c.setup.required_check.clone())
1485 .filter(|name| !name.is_empty()),
1486 Source::Config,
1487 ),
1488 (
1489 record
1490 .map(|r| r.parameters.required_check.clone())
1491 .filter(|name| !name.is_empty()),
1492 Source::Record,
1493 ),
1494 (None, Source::Observation),
1495 (
1496 Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1497 Source::Default,
1498 ),
1499 ])
1500 .unwrap_or((String::new(), Source::Default));
1501 sources.insert("setup.required_check", source);
1502 let (required_workflow, source) = answered([
1503 (flags.required_workflow.map(str::to_owned), Source::Flag),
1504 (
1505 config
1506 .map(|c| c.setup.required_workflow.clone())
1507 .filter(|name| !name.is_empty()),
1508 Source::Config,
1509 ),
1510 (
1511 record
1512 .map(|r| r.parameters.required_workflow.clone())
1513 .filter(|name| !name.is_empty()),
1514 Source::Record,
1515 ),
1516 (None, Source::Observation),
1517 (
1518 Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1519 Source::Default,
1520 ),
1521 ])
1522 .unwrap_or((String::new(), Source::Default));
1523 sources.insert("setup.required_workflow", source);
1524 let recorded = || {
1528 record
1529 .and_then(|r| r.parameters.dev_environment.clone())
1530 .map_or((None, Source::Default), |answer| {
1531 (Some(answer), Source::Record)
1532 })
1533 };
1534 let (dev_environment, source) = config
1535 .and_then(|c| c.dev_environment.clone())
1536 .map_or_else(recorded, |answer| (answer.answered(), Source::Config));
1537 if let Some(answer) = &dev_environment {
1538 answer
1539 .check_forge(forge.as_deref())
1540 .map_err(crate::config::invalid)?;
1541 }
1542 sources.insert("dev_environment", source);
1543 if forge.as_deref() == Some("gitlab") {
1548 for (flag, supplied) in [
1549 ("--required-check", flags.required_check),
1550 ("--required-workflow", flags.required_workflow),
1551 ] {
1552 if supplied.is_some() {
1553 return Err(RkError::Usage(format!(
1554 "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1555 )));
1556 }
1557 }
1558 }
1559
1560 let mut unknown: Vec<String> = technologies
1561 .iter()
1562 .filter(|name| !known_drivers.contains(name))
1563 .map(|name| format!("technology {name}"))
1564 .collect();
1565 if let Some(name) = &forge
1566 && crate::detect::Forge::parse(name).is_none()
1567 {
1568 unknown.push(format!("forge {name}"));
1569 }
1570
1571 Ok(Resolved {
1572 params: Params {
1573 profile: ProfileSnapshot {
1574 technologies,
1575 forge,
1576 release,
1577 },
1578 git: GitWorkflow {
1579 trunk,
1580 checkout_mode,
1581 integration,
1582 merge_form,
1583 },
1584 capabilities: CapabilityRequests {
1585 nix_packaging,
1586 reporting_policy,
1587 scorecard,
1588 code_scanning,
1589 workflow_guards: Some(workflow_guards),
1590 },
1591 repo,
1592 security_contact,
1593 security_response,
1594 required_check,
1595 required_workflow,
1596 dev_environment,
1597 },
1598 sources,
1599 unknown,
1600 proposal,
1601 })
1602}