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 security_scope: String,
270 required_check: String,
271 required_workflow: String,
272 dev_environment: Option<DevEnvironment>,
273}
274
275#[derive(Default)]
277pub struct Inputs<'a> {
278 pub technologies: &'a [String],
281 pub forge: Option<&'a str>,
283 pub repo: Option<&'a str>,
285 pub release_mode: Option<ReleaseMode>,
287 pub release_driver: Option<&'a str>,
289 pub style: Option<Style>,
291 pub trunk: Option<&'a str>,
293 pub checkout_mode: Option<CheckoutMode>,
295 pub integration: Option<Integration>,
297 pub merge_form: Option<MergeForm>,
299 pub required_check: Option<&'a str>,
301 pub required_workflow: Option<&'a str>,
304 pub nix: Option<bool>,
306 pub reporting_policy: Option<bool>,
308 pub scorecard: Option<bool>,
310 pub code_scanning: Option<Option<Provider>>,
313 pub workflow_guards: Option<bool>,
315}
316
317#[derive(Clone, Copy, PartialEq, Eq)]
319pub enum Purpose {
320 Init,
322 Preview,
325 Upgrade,
327 Adopt,
329}
330
331#[derive(Debug, Clone)]
333pub struct Resolved {
334 pub params: Params,
336 pub sources: BTreeMap<&'static str, Source>,
338 pub unknown: Vec<String>,
340 pub proposal: Option<Proposal>,
343}
344
345pub fn canonical_category(raw: &str) -> Result<String, String> {
355 let lowered = raw.trim().to_ascii_lowercase();
356 let shaped = lowered
357 .chars()
358 .next()
359 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
360 && lowered
361 .chars()
362 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
363 if shaped {
364 Ok(lowered)
365 } else {
366 Err(format!(
367 "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
368 ))
369 }
370}
371
372pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
378 let mut out = Vec::with_capacity(raw.len());
379 for value in raw {
380 let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
381 if out.contains(&name) {
382 return Err(format!("{key} names {name} twice; each technology once"));
383 }
384 out.push(name);
385 }
386 out.sort();
387 Ok(out)
388}
389
390fn push_shell_word(out: &mut String, value: &str) {
393 let inert = !value.is_empty()
394 && value.bytes().all(|byte| {
395 byte.is_ascii_alphanumeric()
396 || matches!(
397 byte,
398 b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
399 )
400 });
401 if inert {
402 out.push_str(value);
403 return;
404 }
405 out.push('\'');
406 out.push_str(&value.replace('\'', "'\"'\"'"));
407 out.push('\'');
408}
409
410impl Params {
411 #[must_use]
414 pub fn from_record(record: &manifest::Manifest) -> Self {
415 Self {
416 profile: record.profile.clone(),
417 git: record.git.clone(),
418 capabilities: record.capabilities.clone(),
419 repo: record.parameters.repo.clone(),
420 security_contact: record.parameters.security_contact.clone(),
421 security_response: record.parameters.security_response.clone(),
422 security_scope: record.parameters.security_scope.clone(),
423 required_check: gate_answer(
426 record,
427 |(check, _)| check,
428 |parameters| ¶meters.required_check,
429 ),
430 required_workflow: gate_answer(
431 record,
432 |(_, workflow)| workflow,
433 |parameters| ¶meters.required_workflow,
434 ),
435 dev_environment: record.parameters.dev_environment.clone(),
436 }
437 }
438
439 pub fn resolve(
447 target: &Utf8Path,
448 flags: &Inputs<'_>,
449 config: Option<&crate::config::Config>,
450 record: Option<&manifest::Manifest>,
451 purpose: Purpose,
452 ) -> Result<Self, RkError> {
453 resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
454 }
455
456 #[must_use]
458 pub const fn profile(&self) -> &ProfileSnapshot {
459 &self.profile
460 }
461
462 #[must_use]
464 pub const fn git(&self) -> &GitWorkflow {
465 &self.git
466 }
467
468 #[must_use]
470 pub const fn capabilities(&self) -> &CapabilityRequests {
471 &self.capabilities
472 }
473
474 #[must_use]
476 pub fn technologies(&self) -> &[String] {
477 &self.profile.technologies
478 }
479
480 #[must_use]
482 pub fn forge(&self) -> Option<&str> {
483 self.profile.forge.as_deref()
484 }
485
486 #[must_use]
488 pub const fn release_mode(&self) -> ReleaseMode {
489 self.profile.release.mode
490 }
491
492 #[must_use]
494 pub fn driver(&self) -> Option<&str> {
495 self.profile.release.driver.as_deref()
496 }
497
498 #[must_use]
500 pub const fn style(&self) -> Option<Style> {
501 self.profile.release.style
502 }
503
504 #[must_use]
507 pub fn registry(&self) -> Option<RegistryAnswer> {
508 (self.release_mode() == ReleaseMode::Automatic
509 && self.driver() == Some(RegistryAnswer::DRIVER))
510 .then(|| {
511 self.profile
512 .release
513 .registry
514 .unwrap_or(RegistryAnswer::CratesIo)
515 })
516 }
517
518 #[must_use]
520 pub const fn nix_packaging(&self) -> bool {
521 self.capabilities.nix_packaging
522 }
523
524 #[must_use]
526 pub const fn reporting_policy(&self) -> bool {
527 self.capabilities.reporting_policy
528 }
529
530 #[must_use]
532 pub const fn scorecard(&self) -> bool {
533 self.capabilities.scorecard
534 }
535
536 #[must_use]
538 pub const fn code_scanning(&self) -> Option<Provider> {
539 self.capabilities.code_scanning
540 }
541
542 #[must_use]
544 pub fn workflow_guards(&self) -> bool {
545 self.capabilities.workflow_guards()
546 }
547
548 #[must_use]
551 pub fn repo(&self) -> &str {
552 &self.repo
553 }
554
555 #[must_use]
557 pub const fn checkout_mode(&self) -> CheckoutMode {
558 self.git.checkout_mode
559 }
560
561 #[must_use]
563 pub const fn integration(&self) -> Integration {
564 self.git.integration
565 }
566
567 #[must_use]
569 pub const fn merge_form(&self) -> MergeForm {
570 self.git.merge_form
571 }
572
573 #[must_use]
575 pub fn trunk(&self) -> &str {
576 &self.git.trunk
577 }
578
579 #[must_use]
582 pub fn line_prefix(&self) -> &str {
583 self.profile
584 .release
585 .line_prefix
586 .as_deref()
587 .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
588 }
589
590 #[must_use]
593 pub fn security_contact(&self) -> &str {
594 &self.security_contact
595 }
596
597 #[must_use]
599 pub fn security_response(&self) -> &str {
600 &self.security_response
601 }
602
603 #[must_use]
606 pub fn security_scope(&self) -> &str {
607 &self.security_scope
608 }
609
610 #[must_use]
613 pub fn required_check(&self) -> &str {
614 &self.required_check
615 }
616
617 #[must_use]
620 pub const fn dev_environment(&self) -> Option<&DevEnvironment> {
621 self.dev_environment.as_ref()
622 }
623
624 #[must_use]
627 pub fn required_workflow(&self) -> &str {
628 &self.required_workflow
629 }
630
631 #[must_use]
635 pub fn canonical_flags(&self) -> String {
636 let mut out = String::new();
637 for technology in &self.profile.technologies {
638 out.push_str(" --technology ");
639 out.push_str(technology);
640 }
641 if let Some(forge) = &self.profile.forge {
642 out.push_str(" --forge ");
643 out.push_str(forge);
644 }
645 if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
649 out.push_str(" --repo ");
650 out.push_str(&self.repo);
651 }
652 out.push_str(" --release-mode ");
653 out.push_str(self.profile.release.mode.as_str());
654 if let Some(driver) = &self.profile.release.driver {
655 out.push_str(" --release-driver ");
656 out.push_str(driver);
657 }
658 if let Some(style) = self.profile.release.style {
659 out.push_str(" --release-style ");
660 out.push_str(style.as_str());
661 }
662 out.push_str(" --trunk ");
663 out.push_str(&self.git.trunk);
664 out.push_str(" --checkout-mode ");
665 out.push_str(self.git.checkout_mode.as_str());
666 out.push_str(" --integration ");
667 out.push_str(self.git.integration.as_str());
668 out.push_str(" --merge-form ");
669 out.push_str(self.git.merge_form.as_str());
670 if !self.required_check.is_empty() {
674 out.push_str(" --required-check ");
675 push_shell_word(&mut out, &self.required_check);
676 }
677 if !self.required_workflow.is_empty() {
678 out.push_str(" --required-workflow ");
679 push_shell_word(&mut out, &self.required_workflow);
680 }
681 out
682 }
683
684 #[must_use]
690 pub fn capability_flags(&self) -> String {
691 let mut out = String::new();
692 if self.capabilities.nix_packaging {
693 out.push_str(" --nix-packaging");
694 }
695 if self.capabilities.reporting_policy {
696 out.push_str(" --reporting-policy");
697 }
698 if self.capabilities.scorecard {
699 out.push_str(" --scorecard");
700 }
701 if self.workflow_guards() {
702 out.push_str(" --workflow-guards");
703 }
704 out.push_str(" --code-scanning ");
711 out.push_str(
712 self.capabilities
713 .code_scanning
714 .map_or("off", Provider::as_str),
715 );
716 out
717 }
718
719 #[must_use]
726 pub fn capability_toggles(&self) -> String {
727 let word = |on: bool| if on { "on" } else { "off" };
728 format!(
729 " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {} --workflow-guards {}",
730 word(self.capabilities.nix_packaging),
731 word(self.capabilities.reporting_policy),
732 word(self.capabilities.scorecard),
733 self.capabilities
734 .code_scanning
735 .map_or("off", Provider::as_str),
736 word(self.workflow_guards())
737 )
738 }
739}
740
741#[cfg(test)]
742impl Params {
743 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
748 Self {
749 profile: ProfileSnapshot {
750 technologies: vec!["rust".to_owned()],
751 forge: Some("github".to_owned()),
752 release: ReleaseIntent {
753 mode: ReleaseMode::Automatic,
754 driver: Some("rust".to_owned()),
755 style,
756 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
757 registry: None,
758 },
759 },
760 git: GitWorkflow {
761 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
762 checkout_mode: CheckoutMode::LinkedWorktree,
763 integration: Integration::Local,
764 merge_form: MergeForm::Squash,
765 },
766 capabilities: CapabilityRequests {
767 nix_packaging: false,
768 reporting_policy: true,
769 scorecard: false,
770 code_scanning: None,
771 workflow_guards: Some(true),
772 },
773 repo: repo.to_owned(),
774 security_contact: String::new(),
775 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
776 security_scope: String::new(),
777 required_check: "gate".to_owned(),
778 required_workflow: "ci".to_owned(),
779 dev_environment: None,
780 }
781 }
782
783 pub(crate) fn for_test_security(contact: &str, response: &str, scope: &str) -> Self {
785 Self {
786 security_contact: contact.to_owned(),
787 security_response: response.to_owned(),
788 security_scope: scope.to_owned(),
789 ..Self::for_test("acme/widget", Some(Style::Trunk))
790 }
791 }
792
793 pub(crate) fn for_test_release_less(
796 technologies: &[&str],
797 forge: Option<&str>,
798 mode: ReleaseMode,
799 ) -> Self {
800 let mut params = Self::for_test("acme/widget", None);
801 params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
802 params.profile.forge = forge.map(str::to_owned);
803 params.profile.release = ReleaseIntent {
804 mode,
805 driver: None,
806 style: None,
807 line_prefix: None,
808 registry: None,
809 };
810 params.capabilities.reporting_policy = false;
811 if forge.is_none() {
812 params.repo = String::new();
813 }
814 params
815 }
816
817 pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
819 self.profile.technologies = vec![driver.to_owned()];
820 self.profile.release.driver = Some(driver.to_owned());
821 self.profile.forge = Some(forge.to_owned());
822 }
823
824 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
826 self.git.checkout_mode = mode;
827 }
828
829 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
831 self.git.integration = mode;
832 }
833
834 pub(crate) const fn set_merge_form_for_test(&mut self, form: MergeForm) {
836 self.git.merge_form = form;
837 }
838
839 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
841 self.capabilities.nix_packaging = nix;
842 }
843
844 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
846 self.capabilities.scorecard = scorecard;
847 }
848
849 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
851 self.capabilities.code_scanning = provider;
852 }
853
854 pub(crate) const fn set_workflow_guards_for_test(&mut self, on: bool) {
856 self.capabilities.workflow_guards = Some(on);
857 }
858}
859
860fn invalid_release(message: impl std::fmt::Display) -> RkError {
863 RkError::Usage(format!(
864 "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
865 ))
866}
867
868#[must_use]
890pub fn gate_defaults(
891 profile: &ProfileSnapshot,
892 git: &GitWorkflow,
893) -> Option<(&'static str, &'static str)> {
894 let consuming = profile.forge.as_deref() == Some("github")
895 && profile.release.mode == ReleaseMode::Automatic
896 && profile.release.style == Some(Style::Trunk)
897 && git.integration == Integration::Local;
898 consuming.then_some(("gate", "ci"))
899}
900
901fn gate_answer(
904 record: &manifest::Manifest,
905 pick: impl Fn((&'static str, &'static str)) -> &'static str,
906 held: impl Fn(&manifest::Parameters) -> &String,
907) -> String {
908 let recorded = held(&record.parameters);
909 if recorded.is_empty() {
910 gate_defaults(&record.profile, &record.git)
911 .map_or_else(String::new, |pair| pick(pair).to_owned())
912 } else {
913 recorded.clone()
914 }
915}
916
917fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
919 chain
920 .into_iter()
921 .find_map(|(value, source)| value.map(|value| (value, source)))
922}
923
924#[allow(
932 clippy::too_many_lines,
933 reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
934)]
935pub fn resolve(
936 target: &Utf8Path,
937 flags: &Inputs<'_>,
938 config: Option<&crate::config::Config>,
939 record: Option<&manifest::Manifest>,
940 purpose: Purpose,
941) -> Result<Resolved, RkError> {
942 let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
943 let observed = crate::detect::observe(target.as_std_path());
944 let known_drivers = catalog::known_drivers();
945
946 let (technologies, source) = answered([
948 (
949 (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
950 Source::Flag,
951 ),
952 (
953 config.and_then(|c| c.profile.technologies.clone()),
954 Source::Config,
955 ),
956 (
957 record.map(|r| r.profile.technologies.clone()),
958 Source::Record,
959 ),
960 (
961 Some(
962 observed
963 .technologies
964 .iter()
965 .map(|t| (*t).to_owned())
966 .collect(),
967 ),
968 Source::Observation,
969 ),
970 (None, Source::Default),
971 ])
972 .unwrap_or_else(|| (Vec::new(), Source::Default));
973 let technologies =
974 canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
975 sources.insert("profile.technologies", source);
976
977 let forge_flag = flags
979 .forge
980 .map(|name| canonical_category(name).map_err(RkError::Usage))
981 .transpose()?;
982 let (forge, source) = answered([
983 (forge_flag.map(Some), Source::Flag),
984 (
985 config
986 .and_then(|c| c.profile.forge.clone())
987 .map(|value| if value.is_empty() { None } else { Some(value) }),
988 Source::Config,
989 ),
990 (record.map(|r| r.profile.forge.clone()), Source::Record),
991 (
992 observed.forge.map(|forge| Some(forge.as_str().to_owned())),
993 Source::Observation,
994 ),
995 (Some(None), Source::Default),
996 ])
997 .unwrap_or((None, Source::Default));
998 let forge = forge
999 .map(|name| canonical_category(&name).map_err(RkError::Usage))
1000 .transpose()?;
1001 sources.insert("profile.forge", source);
1002
1003 let (repo, source) = answered([
1005 (flags.repo.map(str::to_owned), Source::Flag),
1006 (
1007 config
1008 .map(|c| c.project.repo.clone())
1009 .filter(|value| !value.is_empty()),
1010 Source::Config,
1011 ),
1012 (
1013 record
1014 .map(|r| r.parameters.repo.clone())
1015 .filter(|value| !value.is_empty()),
1016 Source::Record,
1017 ),
1018 (observed.repo.clone(), Source::Observation),
1019 (None, Source::Default),
1020 ])
1021 .map_or((None, Source::Default), |(value, source)| {
1022 (Some(value), source)
1023 });
1024 sources.insert("project.repo", source);
1025
1026 let release_bearing: Vec<String> = technologies
1029 .iter()
1030 .filter(|name| known_drivers.contains(name))
1031 .cloned()
1032 .collect();
1033 let proposal = match release_bearing.as_slice() {
1034 [] => Proposal::None,
1035 [one] => Proposal::Automatic {
1036 driver: one.clone(),
1037 },
1038 many => Proposal::Ambiguous {
1039 drivers: many.to_vec(),
1040 },
1041 };
1042 let proposed_mode = match &proposal {
1049 Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
1050 Proposal::None => ReleaseMode::None,
1051 };
1052 let (mode, source) = answered([
1053 (flags.release_mode, Source::Flag),
1054 (config.and_then(|c| c.profile.release.mode), Source::Config),
1055 (record.map(|r| r.profile.release.mode), Source::Record),
1056 (Some(proposed_mode), Source::Observation),
1057 (None, Source::Default),
1058 ])
1059 .unwrap_or((ReleaseMode::None, Source::Default));
1060 let mode_source = source;
1061 sources.insert("profile.release.mode", mode_source);
1062 let mode_answered_above = mode_source != Source::Observation;
1063 let proposal = (!mode_answered_above).then_some(proposal);
1064
1065 let driver_flag = flags
1069 .release_driver
1070 .map(|name| canonical_category(name).map_err(RkError::Usage))
1071 .transpose()?;
1072 let (driver, driver_source) = answered([
1073 (driver_flag, Source::Flag),
1074 (
1075 config.and_then(|c| c.profile.release.driver.clone()),
1076 Source::Config,
1077 ),
1078 (
1079 record.and_then(|r| r.profile.release.driver.clone()),
1080 Source::Record,
1081 ),
1082 (
1083 match &proposal {
1084 Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
1085 Some(driver.clone())
1086 }
1087 _ => None,
1088 },
1089 Source::Observation,
1090 ),
1091 (None, Source::Default),
1092 ])
1093 .map_or((None, Source::Default), |(value, source)| {
1094 (Some(value), source)
1095 });
1096 let (style, style_source) = answered([
1097 (flags.style, Source::Flag),
1098 (config.and_then(|c| c.profile.release.style), Source::Config),
1099 (record.and_then(|r| r.profile.release.style), Source::Record),
1100 (None, Source::Observation),
1101 (
1102 (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
1103 .then_some(Style::Trunk),
1104 Source::Default,
1105 ),
1106 ])
1107 .map_or((None, Source::Default), |(value, source)| {
1108 (Some(value), source)
1109 });
1110 let (line_prefix, prefix_source) = answered([
1111 (None, Source::Flag),
1112 (
1113 config.and_then(|c| c.profile.release.line_prefix.clone()),
1114 Source::Config,
1115 ),
1116 (
1117 record.and_then(|r| r.profile.release.line_prefix.clone()),
1118 Source::Record,
1119 ),
1120 (None, Source::Observation),
1121 (
1122 (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1123 Source::Default,
1124 ),
1125 ])
1126 .map_or((None, Source::Default), |(value, source)| {
1127 (Some(value), source)
1128 });
1129
1130 let (registry, registry_source) = answered([
1134 (None, Source::Flag),
1135 (
1136 config.and_then(|c| c.profile.release.registry),
1137 Source::Config,
1138 ),
1139 (
1140 record.and_then(|r| r.profile.release.registry),
1141 Source::Record,
1142 ),
1143 (None, Source::Observation),
1144 (None, Source::Default),
1145 ])
1146 .map_or((None, Source::Default), |(value, source)| {
1147 (Some(value), source)
1148 });
1149
1150 let reporting = purpose == Purpose::Preview;
1155 let release = match mode {
1156 ReleaseMode::Automatic => {
1157 if let Some(Proposal::Ambiguous { drivers }) = &proposal
1158 && driver.is_none()
1159 && !reporting
1160 {
1161 return Err(RkError::Usage(format!(
1162 "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1163 drivers.join(" and "),
1164 crate::config::CONFIG_PATH
1165 )));
1166 }
1167 if forge.is_none() && !reporting {
1168 let message = observed.host.map_or_else(
1169 || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1170 |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1171 );
1172 return Err(RkError::refusal(
1173 Diagnostic::new(Reason::ForgeUndetected, message)
1174 .expected("a github.com or gitlab remote, or --forge")
1175 .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1176 ));
1177 }
1178 if driver.is_none() && !reporting {
1179 return Err(invalid_release(format!(
1180 "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1181 known_drivers.join("|")
1182 )));
1183 }
1184 if let Some(driver) = &driver
1185 && !technologies.contains(driver)
1186 && !reporting
1187 {
1188 return Err(invalid_release(format!(
1189 "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1190 if technologies.is_empty() {
1191 "empty".to_owned()
1192 } else {
1193 technologies.join(", ")
1194 }
1195 )));
1196 }
1197 let style = match (style, purpose) {
1198 (None, Purpose::Upgrade | Purpose::Adopt) => {
1199 return Err(RkError::Usage(
1200 "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1201 ));
1202 }
1203 (None, _) => Style::Trunk,
1204 (Some(style), _) => style,
1205 };
1206 if registry.is_some() && (driver.is_some() || !reporting) {
1209 catalog::registry_answer_fits(mode, driver.as_deref()).map_err(RkError::Usage)?;
1210 }
1211 sources.insert("profile.release.driver", driver_source);
1212 sources.insert("profile.release.style", style_source);
1213 sources.insert("profile.release.line_prefix", prefix_source);
1214 if driver.as_deref() == Some(RegistryAnswer::DRIVER) {
1215 sources.insert("profile.release.registry", registry_source);
1216 }
1217 ReleaseIntent {
1218 mode,
1219 driver,
1220 style: Some(style),
1221 line_prefix: Some(
1222 line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1223 ),
1224 registry,
1225 }
1226 }
1227 ReleaseMode::External | ReleaseMode::None => {
1228 for (key, present, value_source) in [
1235 ("profile.release.driver", driver.is_some(), driver_source),
1236 ("profile.release.style", style.is_some(), style_source),
1237 (
1238 "profile.release.line_prefix",
1239 line_prefix.is_some(),
1240 prefix_source,
1241 ),
1242 (
1243 "profile.release.registry",
1244 registry.is_some(),
1245 registry_source,
1246 ),
1247 ] {
1248 if present
1249 && matches!(value_source, Source::Flag | Source::Config)
1250 && value_source.rank() <= mode_source.rank()
1251 {
1252 return Err(invalid_release(format!(
1253 "{key} is set while profile.release.mode is {}",
1254 mode.as_str()
1255 )));
1256 }
1257 }
1258 ReleaseIntent {
1259 mode,
1260 driver: None,
1261 style: None,
1262 line_prefix: None,
1263 registry: None,
1264 }
1265 }
1266 };
1267
1268 let adapter_known = forge
1272 .as_deref()
1273 .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1274 let repo = match (forge.is_some(), adapter_known, repo) {
1275 (false, _, _) => String::new(),
1278 (true, false, repo) => repo.unwrap_or_default(),
1279 (true, true, Some(repo)) => repo,
1280 (true, true, None) if purpose == Purpose::Preview => {
1281 crate::projection::REPO_PLACEHOLDER.to_owned()
1282 }
1283 (true, true, None) => return Err(crate::landing::repo_unresolved()),
1284 };
1285
1286 let (trunk, source) = answered([
1288 (flags.trunk.map(str::to_owned), Source::Flag),
1289 (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1290 (record.map(|r| r.git.trunk.clone()), Source::Record),
1291 (None, Source::Observation),
1292 (
1293 Some(crate::config::TRUNK_DEFAULT.to_owned()),
1294 Source::Default,
1295 ),
1296 ])
1297 .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1298 sources.insert("git.trunk", source);
1299 let (checkout_mode, source) = answered([
1300 (flags.checkout_mode, Source::Flag),
1301 (config.and_then(|c| c.git.checkout_mode), Source::Config),
1302 (record.map(|r| r.git.checkout_mode), Source::Record),
1303 (None, Source::Observation),
1304 (
1305 Some(if purpose == Purpose::Adopt {
1306 CheckoutMode::MainWorktree
1307 } else {
1308 CheckoutMode::LinkedWorktree
1309 }),
1310 Source::Default,
1311 ),
1312 ])
1313 .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1314 sources.insert("git.checkout_mode", source);
1315 let (integration, source) = answered([
1320 (flags.integration, Source::Flag),
1321 (config.and_then(|c| c.git.integration), Source::Config),
1322 (record.map(|r| r.git.integration), Source::Record),
1323 (None, Source::Observation),
1324 (
1329 Some(if purpose == Purpose::Adopt {
1330 Integration::Forge
1331 } else {
1332 Integration::Local
1333 }),
1334 Source::Default,
1335 ),
1336 ])
1337 .unwrap_or((Integration::Local, Source::Default));
1338 sources.insert("git.integration", source);
1339 let (merge_form, source) = answered([
1343 (flags.merge_form, Source::Flag),
1344 (config.and_then(|c| c.git.merge_form), Source::Config),
1345 (record.map(|r| r.git.merge_form), Source::Record),
1346 (None, Source::Observation),
1347 (Some(manifest::merge_form_squash()), Source::Default),
1348 ])
1349 .unwrap_or((MergeForm::Squash, Source::Default));
1350 sources.insert("git.merge_form", source);
1351
1352 let (nix_packaging, source) = answered([
1354 (flags.nix, Source::Flag),
1355 (
1356 config.and_then(|c| c.capabilities.nix_packaging),
1357 Source::Config,
1358 ),
1359 (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1360 (None, Source::Observation),
1361 (Some(false), Source::Default),
1362 ])
1363 .unwrap_or((false, Source::Default));
1364 sources.insert("capabilities.nix_packaging", source);
1365 let (reporting_policy, source) = answered([
1366 (flags.reporting_policy, Source::Flag),
1367 (
1368 config.and_then(|c| c.capabilities.reporting_policy),
1369 Source::Config,
1370 ),
1371 (
1372 record.map(|r| r.capabilities.reporting_policy),
1373 Source::Record,
1374 ),
1375 (None, Source::Observation),
1376 (
1379 Some(release.mode == ReleaseMode::Automatic),
1380 Source::Default,
1381 ),
1382 ])
1383 .unwrap_or((false, Source::Default));
1384 sources.insert("capabilities.reporting_policy", source);
1385 let (scorecard, source) = answered([
1386 (flags.scorecard, Source::Flag),
1387 (
1388 config.and_then(|c| c.capabilities.scorecard),
1389 Source::Config,
1390 ),
1391 (record.map(|r| r.capabilities.scorecard), Source::Record),
1392 (None, Source::Observation),
1393 (Some(false), Source::Default),
1394 ])
1395 .unwrap_or((false, Source::Default));
1396 sources.insert("capabilities.scorecard", source);
1397 let configured_scanning = config
1398 .and_then(|c| c.capabilities.code_scanning.as_deref())
1399 .map(Provider::parse)
1400 .transpose()?;
1401 let (code_scanning, source) = answered([
1402 (flags.code_scanning, Source::Flag),
1403 (configured_scanning, Source::Config),
1404 (record.map(|r| r.capabilities.code_scanning), Source::Record),
1405 (None, Source::Observation),
1406 (Some(None), Source::Default),
1407 ])
1408 .unwrap_or((None, Source::Default));
1409 sources.insert("capabilities.code_scanning", source);
1410 let (workflow_guards, source) = answered([
1415 (flags.workflow_guards, Source::Flag),
1416 (
1417 config.and_then(|c| c.capabilities.workflow_guards),
1418 Source::Config,
1419 ),
1420 (
1421 record.map(|r| r.capabilities.workflow_guards()),
1422 Source::Record,
1423 ),
1424 (None, Source::Observation),
1425 (Some(false), Source::Default),
1426 ])
1427 .unwrap_or((false, Source::Default));
1428 sources.insert("capabilities.workflow_guards", source);
1429 let (security_contact, source) = answered([
1437 (None, Source::Flag),
1438 (
1439 config.and_then(|c| c.security.contact.clone()),
1440 Source::Config,
1441 ),
1442 (
1443 record.map(|r| r.parameters.security_contact.clone()),
1444 Source::Record,
1445 ),
1446 (None, Source::Observation),
1447 (Some(String::new()), Source::Default),
1448 ])
1449 .unwrap_or((String::new(), Source::Default));
1450 let security_contact =
1451 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1452 sources.insert("security.contact", source);
1453 let (security_response, source) = answered([
1454 (None, Source::Flag),
1455 (
1456 config.and_then(|c| c.security.response.clone()),
1457 Source::Config,
1458 ),
1459 (
1460 record.map(|r| r.parameters.security_response.clone()),
1461 Source::Record,
1462 ),
1463 (None, Source::Observation),
1464 (
1465 Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1466 Source::Default,
1467 ),
1468 ])
1469 .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1470 let security_response =
1471 crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1472 sources.insert("security.response", source);
1473 let (security_scope, source) = answered([
1474 (None, Source::Flag),
1475 (
1476 config.and_then(|c| c.security.scope.clone()),
1477 Source::Config,
1478 ),
1479 (
1480 record.map(|r| r.parameters.security_scope.clone()),
1481 Source::Record,
1482 ),
1483 (None, Source::Observation),
1484 (Some(String::new()), Source::Default),
1485 ])
1486 .unwrap_or((String::new(), Source::Default));
1487 let security_scope =
1488 crate::config::canonical_scope(&security_scope).map_err(crate::config::invalid)?;
1489 sources.insert("security.scope", source);
1490
1491 let gate_default = gate_defaults(
1496 &ProfileSnapshot {
1497 technologies: technologies.clone(),
1498 forge: forge.clone(),
1499 release: release.clone(),
1500 },
1501 &GitWorkflow {
1502 trunk: trunk.clone(),
1503 checkout_mode,
1504 integration,
1505 merge_form,
1506 },
1507 );
1508 let (required_check, source) = answered([
1509 (flags.required_check.map(str::to_owned), Source::Flag),
1510 (
1511 config
1512 .map(|c| c.setup.required_check.clone())
1513 .filter(|name| !name.is_empty()),
1514 Source::Config,
1515 ),
1516 (
1517 record
1518 .map(|r| r.parameters.required_check.clone())
1519 .filter(|name| !name.is_empty()),
1520 Source::Record,
1521 ),
1522 (None, Source::Observation),
1523 (
1524 Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1525 Source::Default,
1526 ),
1527 ])
1528 .unwrap_or((String::new(), Source::Default));
1529 sources.insert("setup.required_check", source);
1530 let (required_workflow, source) = answered([
1531 (flags.required_workflow.map(str::to_owned), Source::Flag),
1532 (
1533 config
1534 .map(|c| c.setup.required_workflow.clone())
1535 .filter(|name| !name.is_empty()),
1536 Source::Config,
1537 ),
1538 (
1539 record
1540 .map(|r| r.parameters.required_workflow.clone())
1541 .filter(|name| !name.is_empty()),
1542 Source::Record,
1543 ),
1544 (None, Source::Observation),
1545 (
1546 Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1547 Source::Default,
1548 ),
1549 ])
1550 .unwrap_or((String::new(), Source::Default));
1551 sources.insert("setup.required_workflow", source);
1552 let recorded = || {
1556 record
1557 .and_then(|r| r.parameters.dev_environment.clone())
1558 .map_or((None, Source::Default), |answer| {
1559 (Some(answer), Source::Record)
1560 })
1561 };
1562 let (dev_environment, source) = config
1563 .and_then(|c| c.dev_environment.clone())
1564 .map_or_else(recorded, |answer| (answer.answered(), Source::Config));
1565 if let Some(answer) = &dev_environment {
1566 answer
1567 .check_forge(forge.as_deref())
1568 .map_err(crate::config::invalid)?;
1569 }
1570 sources.insert("dev_environment", source);
1571 if forge.as_deref() == Some("gitlab") {
1576 for (flag, supplied) in [
1577 ("--required-check", flags.required_check),
1578 ("--required-workflow", flags.required_workflow),
1579 ] {
1580 if supplied.is_some() {
1581 return Err(RkError::Usage(format!(
1582 "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1583 )));
1584 }
1585 }
1586 }
1587
1588 let mut unknown: Vec<String> = technologies
1589 .iter()
1590 .filter(|name| !known_drivers.contains(name))
1591 .map(|name| format!("technology {name}"))
1592 .collect();
1593 if let Some(name) = &forge
1594 && crate::detect::Forge::parse(name).is_none()
1595 {
1596 unknown.push(format!("forge {name}"));
1597 }
1598
1599 Ok(Resolved {
1600 params: Params {
1601 profile: ProfileSnapshot {
1602 technologies,
1603 forge,
1604 release,
1605 },
1606 git: GitWorkflow {
1607 trunk,
1608 checkout_mode,
1609 integration,
1610 merge_form,
1611 },
1612 capabilities: CapabilityRequests {
1613 nix_packaging,
1614 reporting_policy,
1615 scorecard,
1616 code_scanning,
1617 workflow_guards: Some(workflow_guards),
1618 },
1619 repo,
1620 security_contact,
1621 security_response,
1622 security_scope,
1623 required_check,
1624 required_workflow,
1625 dev_environment,
1626 },
1627 sources,
1628 unknown,
1629 proposal,
1630 })
1631}