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)]
134#[serde(deny_unknown_fields, default)]
135pub struct BuildAnswer {
136 #[serde(skip_serializing_if = "Option::is_none")]
140 pub command: Option<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
144 pub packages: Option<String>,
145 #[serde(skip_serializing_if = "Option::is_none")]
148 pub include: Option<Vec<String>>,
149 #[serde(skip_serializing_if = "Option::is_none")]
152 pub exclude: Option<Vec<String>>,
153 #[serde(skip_serializing_if = "Option::is_none")]
156 pub verify: Option<String>,
157}
158
159impl BuildAnswer {
160 pub const DRIVER: &'static str = "bash";
162 pub const PACKAGES: &'static str = "make";
164
165 #[must_use]
167 pub const fn is_empty(&self) -> bool {
168 self.command.is_none()
169 && self.packages.is_none()
170 && self.include.is_none()
171 && self.exclude.is_none()
172 && self.verify.is_none()
173 }
174}
175
176pub const RETIRED_BUILD_COMMAND: &str = "profile.release.build.command";
179
180#[must_use]
188pub fn retired_build_command() -> RkError {
189 RkError::refusal(
190 Diagnostic::new(
191 Reason::ConfigInvalid,
192 format!(
193 "{RETIRED_BUILD_COMMAND} is retired: rk release build packs the archive itself from the tracked files that profile.release.build.include and profile.release.build.exclude select, and runs profile.release.build.verify in it"
194 ),
195 )
196 .expected(format!(
197 "a [profile.release.build] table without {RETIRED_BUILD_COMMAND}, in .release-kit/config.toml and in the landing record"
198 ))
199 .action(format!(
200 "replace {RETIRED_BUILD_COMMAND} in .release-kit/config.toml with profile.release.build.include and profile.release.build.exclude, and with profile.release.build.verify where the command checked something, then run rk upgrade --apply, as guidance/0.9.15.md states for a target that records the command"
201 ))
202 .target_state("unchanged"),
203 )
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct ReleaseIntent {
212 pub mode: ReleaseMode,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub driver: Option<String>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub style: Option<Style>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub line_prefix: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub registry: Option<RegistryAnswer>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub build: Option<BuildAnswer>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct ProfileSnapshot {
240 pub technologies: Vec<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub forge: Option<String>,
245 pub release: ReleaseIntent,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251pub struct GitWorkflow {
252 pub trunk: String,
254 pub checkout_mode: CheckoutMode,
256 #[serde(default = "crate::landing::manifest::integration_forge")]
259 pub integration: Integration,
260 #[serde(default = "crate::landing::manifest::merge_form_squash")]
263 pub merge_form: MergeForm,
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268pub struct CapabilityRequests {
269 #[serde(default)]
271 pub nix_packaging: bool,
272 #[serde(default)]
274 pub reporting_policy: bool,
275 #[serde(default)]
277 pub scorecard: bool,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub code_scanning: Option<Provider>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub workflow_guards: Option<bool>,
287}
288
289impl CapabilityRequests {
290 #[must_use]
294 pub fn workflow_guards(&self) -> bool {
295 self.workflow_guards.unwrap_or(true)
296 }
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
301#[serde(rename_all = "lowercase")]
302pub enum Source {
303 Flag,
305 Config,
307 Record,
309 Observation,
311 Default,
313}
314
315impl Source {
316 #[must_use]
322 pub const fn rank(self) -> u8 {
323 match self {
324 Self::Flag => 0,
325 Self::Config => 1,
326 Self::Record => 2,
327 Self::Observation => 3,
328 Self::Default => 4,
329 }
330 }
331
332 #[must_use]
334 pub const fn as_str(self) -> &'static str {
335 match self {
336 Self::Flag => "flag",
337 Self::Config => "config",
338 Self::Record => "record",
339 Self::Observation => "observation",
340 Self::Default => "default",
341 }
342 }
343}
344
345#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
348#[serde(rename_all = "kebab-case", tag = "state")]
349pub enum Proposal {
350 None,
352 Automatic {
354 driver: String,
356 },
357 Ambiguous {
360 drivers: Vec<String>,
362 },
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct Params {
371 profile: ProfileSnapshot,
372 git: GitWorkflow,
373 capabilities: CapabilityRequests,
374 repo: String,
375 security_contact: String,
376 security_response: String,
377 security_scope: String,
378 required_check: String,
379 dev_environment: Option<DevEnvironment>,
380}
381
382#[derive(Default)]
384pub struct Inputs<'a> {
385 pub technologies: &'a [String],
388 pub forge: Option<&'a str>,
390 pub repo: Option<&'a str>,
392 pub release_mode: Option<ReleaseMode>,
394 pub release_driver: Option<&'a str>,
396 pub style: Option<Style>,
398 pub trunk: Option<&'a str>,
400 pub checkout_mode: Option<CheckoutMode>,
402 pub integration: Option<Integration>,
404 pub merge_form: Option<MergeForm>,
406 pub required_check: Option<&'a str>,
408 pub build_packages: Option<&'a str>,
410 pub nix: Option<bool>,
412 pub reporting_policy: Option<bool>,
414 pub scorecard: Option<bool>,
416 pub code_scanning: Option<Option<Provider>>,
419 pub workflow_guards: Option<bool>,
421}
422
423#[derive(Clone, Copy, PartialEq, Eq)]
425pub enum Purpose {
426 Init,
428 Preview,
431 Upgrade,
433 Adopt,
435}
436
437#[derive(Debug, Clone)]
439pub struct Resolved {
440 pub params: Params,
442 pub sources: BTreeMap<&'static str, Source>,
444 pub unknown: Vec<String>,
446 pub proposal: Option<Proposal>,
449 pub bot_name: String,
453}
454
455pub fn canonical_category(raw: &str) -> Result<String, String> {
465 let lowered = raw.trim().to_ascii_lowercase();
466 let shaped = lowered
467 .chars()
468 .next()
469 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
470 && lowered
471 .chars()
472 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
473 if shaped {
474 Ok(lowered)
475 } else {
476 Err(format!(
477 "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
478 ))
479 }
480}
481
482pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
488 let mut out = Vec::with_capacity(raw.len());
489 for value in raw {
490 let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
491 if out.contains(&name) {
492 return Err(format!("{key} names {name} twice; each technology once"));
493 }
494 out.push(name);
495 }
496 out.sort();
497 Ok(out)
498}
499
500fn push_shell_word(out: &mut String, value: &str) {
503 let inert = !value.is_empty()
504 && value.bytes().all(|byte| {
505 byte.is_ascii_alphanumeric()
506 || matches!(
507 byte,
508 b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
509 )
510 });
511 if inert {
512 out.push_str(value);
513 return;
514 }
515 out.push('\'');
516 out.push_str(&value.replace('\'', "'\"'\"'"));
517 out.push('\'');
518}
519
520impl Params {
521 #[must_use]
524 pub fn from_record(record: &manifest::Manifest) -> Self {
525 Self {
526 profile: record.profile.clone(),
527 git: record.git.clone(),
528 capabilities: record.capabilities.clone(),
529 repo: record.parameters.repo.clone(),
530 security_contact: record.parameters.security_contact.clone(),
531 security_response: record.parameters.security_response.clone(),
532 security_scope: record.parameters.security_scope.clone(),
533 required_check: gate_answer(record),
536 dev_environment: record.parameters.dev_environment.clone(),
537 }
538 }
539
540 pub fn resolve(
548 target: &Utf8Path,
549 flags: &Inputs<'_>,
550 config: Option<&crate::config::Config>,
551 record: Option<&manifest::Manifest>,
552 purpose: Purpose,
553 ) -> Result<Self, RkError> {
554 resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
555 }
556
557 #[must_use]
559 pub const fn profile(&self) -> &ProfileSnapshot {
560 &self.profile
561 }
562
563 #[must_use]
565 pub const fn git(&self) -> &GitWorkflow {
566 &self.git
567 }
568
569 #[must_use]
571 pub const fn capabilities(&self) -> &CapabilityRequests {
572 &self.capabilities
573 }
574
575 #[must_use]
577 pub fn technologies(&self) -> &[String] {
578 &self.profile.technologies
579 }
580
581 #[must_use]
583 pub fn forge(&self) -> Option<&str> {
584 self.profile.forge.as_deref()
585 }
586
587 #[must_use]
589 pub const fn release_mode(&self) -> ReleaseMode {
590 self.profile.release.mode
591 }
592
593 #[must_use]
595 pub fn driver(&self) -> Option<&str> {
596 self.profile.release.driver.as_deref()
597 }
598
599 #[must_use]
601 pub const fn style(&self) -> Option<Style> {
602 self.profile.release.style
603 }
604
605 #[must_use]
609 pub fn registry(&self) -> Option<RegistryAnswer> {
610 if self.release_mode() != ReleaseMode::Automatic {
611 return None;
612 }
613 let default = RegistryAnswer::default_for(self.driver()?)?;
614 Some(self.profile.release.registry.unwrap_or(default))
615 }
616
617 #[must_use]
620 pub fn build_packages(&self) -> &str {
621 self.profile
622 .release
623 .build
624 .as_ref()
625 .and_then(|build| build.packages.as_deref())
626 .unwrap_or(BuildAnswer::PACKAGES)
627 }
628
629 #[must_use]
631 pub const fn nix_packaging(&self) -> bool {
632 self.capabilities.nix_packaging
633 }
634
635 #[must_use]
637 pub const fn reporting_policy(&self) -> bool {
638 self.capabilities.reporting_policy
639 }
640
641 #[must_use]
643 pub const fn scorecard(&self) -> bool {
644 self.capabilities.scorecard
645 }
646
647 #[must_use]
649 pub const fn code_scanning(&self) -> Option<Provider> {
650 self.capabilities.code_scanning
651 }
652
653 #[must_use]
655 pub fn workflow_guards(&self) -> bool {
656 self.capabilities.workflow_guards()
657 }
658
659 #[must_use]
662 pub fn repo(&self) -> &str {
663 &self.repo
664 }
665
666 #[must_use]
668 pub const fn checkout_mode(&self) -> CheckoutMode {
669 self.git.checkout_mode
670 }
671
672 #[must_use]
674 pub const fn integration(&self) -> Integration {
675 self.git.integration
676 }
677
678 #[must_use]
680 pub const fn merge_form(&self) -> MergeForm {
681 self.git.merge_form
682 }
683
684 #[must_use]
686 pub fn trunk(&self) -> &str {
687 &self.git.trunk
688 }
689
690 #[must_use]
693 pub fn line_prefix(&self) -> &str {
694 self.profile
695 .release
696 .line_prefix
697 .as_deref()
698 .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
699 }
700
701 #[must_use]
704 pub fn security_contact(&self) -> &str {
705 &self.security_contact
706 }
707
708 #[must_use]
710 pub fn security_response(&self) -> &str {
711 &self.security_response
712 }
713
714 #[must_use]
717 pub fn security_scope(&self) -> &str {
718 &self.security_scope
719 }
720
721 #[must_use]
724 pub fn required_check(&self) -> &str {
725 &self.required_check
726 }
727
728 #[must_use]
731 pub const fn dev_environment(&self) -> Option<&DevEnvironment> {
732 self.dev_environment.as_ref()
733 }
734
735 #[must_use]
739 pub fn canonical_flags(&self) -> String {
740 let mut out = String::new();
741 for technology in &self.profile.technologies {
742 out.push_str(" --technology ");
743 out.push_str(technology);
744 }
745 if let Some(forge) = &self.profile.forge {
746 out.push_str(" --forge ");
747 out.push_str(forge);
748 }
749 if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
753 out.push_str(" --repo ");
754 out.push_str(&self.repo);
755 }
756 out.push_str(" --release-mode ");
757 out.push_str(self.profile.release.mode.as_str());
758 if let Some(driver) = &self.profile.release.driver {
759 out.push_str(" --release-driver ");
760 out.push_str(driver);
761 }
762 if let Some(style) = self.profile.release.style {
763 out.push_str(" --release-style ");
764 out.push_str(style.as_str());
765 }
766 out.push_str(" --trunk ");
767 out.push_str(&self.git.trunk);
768 out.push_str(" --checkout-mode ");
769 out.push_str(self.git.checkout_mode.as_str());
770 out.push_str(" --integration ");
771 out.push_str(self.git.integration.as_str());
772 out.push_str(" --merge-form ");
773 out.push_str(self.git.merge_form.as_str());
774 if !self.required_check.is_empty() {
778 out.push_str(" --required-check ");
779 push_shell_word(&mut out, &self.required_check);
780 }
781 out
782 }
783
784 #[must_use]
790 pub fn capability_flags(&self) -> String {
791 let mut out = String::new();
792 if self.capabilities.nix_packaging {
793 out.push_str(" --nix-packaging");
794 }
795 if self.capabilities.reporting_policy {
796 out.push_str(" --reporting-policy");
797 }
798 if self.capabilities.scorecard {
799 out.push_str(" --scorecard");
800 }
801 if self.workflow_guards() {
802 out.push_str(" --workflow-guards");
803 }
804 out.push_str(" --code-scanning ");
811 out.push_str(
812 self.capabilities
813 .code_scanning
814 .map_or("off", Provider::as_str),
815 );
816 out
817 }
818
819 #[must_use]
826 pub fn capability_toggles(&self) -> String {
827 let word = |on: bool| if on { "on" } else { "off" };
828 format!(
829 " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {} --workflow-guards {}",
830 word(self.capabilities.nix_packaging),
831 word(self.capabilities.reporting_policy),
832 word(self.capabilities.scorecard),
833 self.capabilities
834 .code_scanning
835 .map_or("off", Provider::as_str),
836 word(self.workflow_guards())
837 )
838 }
839}
840
841#[cfg(test)]
842impl Params {
843 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
848 Self {
849 profile: ProfileSnapshot {
850 technologies: vec!["rust".to_owned()],
851 forge: Some("github".to_owned()),
852 release: ReleaseIntent {
853 mode: ReleaseMode::Automatic,
854 driver: Some("rust".to_owned()),
855 style,
856 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
857 registry: None,
858 build: None,
859 },
860 },
861 git: GitWorkflow {
862 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
863 checkout_mode: CheckoutMode::LinkedWorktree,
864 integration: Integration::Local,
865 merge_form: MergeForm::Squash,
866 },
867 capabilities: CapabilityRequests {
868 nix_packaging: false,
869 reporting_policy: true,
870 scorecard: false,
871 code_scanning: None,
872 workflow_guards: Some(true),
873 },
874 repo: repo.to_owned(),
875 security_contact: String::new(),
876 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
877 security_scope: String::new(),
878 required_check: "gate".to_owned(),
879 dev_environment: None,
880 }
881 }
882
883 pub(crate) fn for_test_security(contact: &str, response: &str, scope: &str) -> Self {
885 Self {
886 security_contact: contact.to_owned(),
887 security_response: response.to_owned(),
888 security_scope: scope.to_owned(),
889 ..Self::for_test("acme/widget", Some(Style::Trunk))
890 }
891 }
892
893 pub(crate) fn for_test_release_less(
896 technologies: &[&str],
897 forge: Option<&str>,
898 mode: ReleaseMode,
899 ) -> Self {
900 let mut params = Self::for_test("acme/widget", None);
901 params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
902 params.profile.forge = forge.map(str::to_owned);
903 params.profile.release = ReleaseIntent {
904 mode,
905 driver: None,
906 style: None,
907 line_prefix: None,
908 registry: None,
909 build: None,
910 };
911 params.capabilities.reporting_policy = false;
912 if forge.is_none() {
913 params.repo = String::new();
914 }
915 params
916 }
917
918 pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
920 self.profile.technologies = vec![driver.to_owned()];
921 self.profile.release.driver = Some(driver.to_owned());
922 self.profile.forge = Some(forge.to_owned());
923 }
924
925 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
927 self.git.checkout_mode = mode;
928 }
929
930 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
932 self.git.integration = mode;
933 }
934
935 pub(crate) const fn set_merge_form_for_test(&mut self, form: MergeForm) {
937 self.git.merge_form = form;
938 }
939
940 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
942 self.capabilities.nix_packaging = nix;
943 }
944
945 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
947 self.capabilities.scorecard = scorecard;
948 }
949
950 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
952 self.capabilities.code_scanning = provider;
953 }
954
955 pub(crate) const fn set_workflow_guards_for_test(&mut self, on: bool) {
957 self.capabilities.workflow_guards = Some(on);
958 }
959}
960
961fn invalid_release(message: impl std::fmt::Display) -> RkError {
964 RkError::Usage(format!(
965 "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
966 ))
967}
968
969#[must_use]
987pub fn gate_defaults(profile: &ProfileSnapshot) -> Option<&'static str> {
988 let consuming = profile.forge.as_deref() == Some("github")
989 && profile.release.mode == ReleaseMode::Automatic
990 && profile.release.style == Some(Style::Trunk);
991 consuming.then_some("gate")
992}
993
994fn gate_answer(record: &manifest::Manifest) -> String {
997 let recorded = &record.parameters.required_check;
998 if recorded.is_empty() {
999 gate_defaults(&record.profile).map_or_else(String::new, str::to_owned)
1000 } else {
1001 recorded.clone()
1002 }
1003}
1004
1005fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
1007 chain
1008 .into_iter()
1009 .find_map(|(value, source)| value.map(|value| (value, source)))
1010}
1011
1012#[allow(
1020 clippy::too_many_lines,
1021 reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
1022)]
1023pub fn resolve(
1024 target: &Utf8Path,
1025 flags: &Inputs<'_>,
1026 config: Option<&crate::config::Config>,
1027 record: Option<&manifest::Manifest>,
1028 purpose: Purpose,
1029) -> Result<Resolved, RkError> {
1030 let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
1031 let observed = crate::detect::observe(target.as_std_path());
1032 let known_drivers = catalog::known_drivers();
1033
1034 let (technologies, source) = answered([
1036 (
1037 (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
1038 Source::Flag,
1039 ),
1040 (
1041 config.and_then(|c| c.profile.technologies.clone()),
1042 Source::Config,
1043 ),
1044 (
1045 record.map(|r| r.profile.technologies.clone()),
1046 Source::Record,
1047 ),
1048 (
1049 Some(
1050 observed
1051 .technologies
1052 .iter()
1053 .map(|t| (*t).to_owned())
1054 .collect(),
1055 ),
1056 Source::Observation,
1057 ),
1058 (None, Source::Default),
1059 ])
1060 .unwrap_or_else(|| (Vec::new(), Source::Default));
1061 let technologies =
1062 canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
1063 sources.insert("profile.technologies", source);
1064
1065 let forge_flag = flags
1067 .forge
1068 .map(|name| canonical_category(name).map_err(RkError::Usage))
1069 .transpose()?;
1070 let (forge, source) = answered([
1071 (forge_flag.map(Some), Source::Flag),
1072 (
1073 config
1074 .and_then(|c| c.profile.forge.clone())
1075 .map(|value| if value.is_empty() { None } else { Some(value) }),
1076 Source::Config,
1077 ),
1078 (record.map(|r| r.profile.forge.clone()), Source::Record),
1079 (
1080 observed.forge.map(|forge| Some(forge.as_str().to_owned())),
1081 Source::Observation,
1082 ),
1083 (Some(None), Source::Default),
1084 ])
1085 .unwrap_or((None, Source::Default));
1086 let forge = forge
1087 .map(|name| canonical_category(&name).map_err(RkError::Usage))
1088 .transpose()?;
1089 sources.insert("profile.forge", source);
1090
1091 let (repo, source) = answered([
1093 (flags.repo.map(str::to_owned), Source::Flag),
1094 (
1095 config
1096 .map(|c| c.project.repo.clone())
1097 .filter(|value| !value.is_empty()),
1098 Source::Config,
1099 ),
1100 (
1101 record
1102 .map(|r| r.parameters.repo.clone())
1103 .filter(|value| !value.is_empty()),
1104 Source::Record,
1105 ),
1106 (observed.repo.clone(), Source::Observation),
1107 (None, Source::Default),
1108 ])
1109 .map_or((None, Source::Default), |(value, source)| {
1110 (Some(value), source)
1111 });
1112 sources.insert("project.repo", source);
1113
1114 let release_bearing: Vec<String> = technologies
1117 .iter()
1118 .filter(|name| known_drivers.contains(name))
1119 .cloned()
1120 .collect();
1121 let proposal = match release_bearing.as_slice() {
1122 [] => Proposal::None,
1123 [one] => Proposal::Automatic {
1124 driver: one.clone(),
1125 },
1126 many => Proposal::Ambiguous {
1127 drivers: many.to_vec(),
1128 },
1129 };
1130 let proposed_mode = match &proposal {
1137 Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
1138 Proposal::None => ReleaseMode::None,
1139 };
1140 let (mode, source) = answered([
1141 (flags.release_mode, Source::Flag),
1142 (config.and_then(|c| c.profile.release.mode), Source::Config),
1143 (record.map(|r| r.profile.release.mode), Source::Record),
1144 (Some(proposed_mode), Source::Observation),
1145 (None, Source::Default),
1146 ])
1147 .unwrap_or((ReleaseMode::None, Source::Default));
1148 let mode_source = source;
1149 sources.insert("profile.release.mode", mode_source);
1150 let mode_answered_above = mode_source != Source::Observation;
1151 let proposal = (!mode_answered_above).then_some(proposal);
1152
1153 let driver_flag = flags
1157 .release_driver
1158 .map(|name| canonical_category(name).map_err(RkError::Usage))
1159 .transpose()?;
1160 let (driver, driver_source) = answered([
1161 (driver_flag, Source::Flag),
1162 (
1163 config.and_then(|c| c.profile.release.driver.clone()),
1164 Source::Config,
1165 ),
1166 (
1167 record.and_then(|r| r.profile.release.driver.clone()),
1168 Source::Record,
1169 ),
1170 (
1171 match &proposal {
1172 Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
1173 Some(driver.clone())
1174 }
1175 _ => None,
1176 },
1177 Source::Observation,
1178 ),
1179 (None, Source::Default),
1180 ])
1181 .map_or((None, Source::Default), |(value, source)| {
1182 (Some(value), source)
1183 });
1184 let (style, style_source) = answered([
1185 (flags.style, Source::Flag),
1186 (config.and_then(|c| c.profile.release.style), Source::Config),
1187 (record.and_then(|r| r.profile.release.style), Source::Record),
1188 (None, Source::Observation),
1189 (
1190 (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
1191 .then_some(Style::Trunk),
1192 Source::Default,
1193 ),
1194 ])
1195 .map_or((None, Source::Default), |(value, source)| {
1196 (Some(value), source)
1197 });
1198 let (line_prefix, prefix_source) = answered([
1199 (None, Source::Flag),
1200 (
1201 config.and_then(|c| c.profile.release.line_prefix.clone()),
1202 Source::Config,
1203 ),
1204 (
1205 record.and_then(|r| r.profile.release.line_prefix.clone()),
1206 Source::Record,
1207 ),
1208 (None, Source::Observation),
1209 (
1210 (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1211 Source::Default,
1212 ),
1213 ])
1214 .map_or((None, Source::Default), |(value, source)| {
1215 (Some(value), source)
1216 });
1217
1218 let (registry, registry_source) = answered([
1223 (None, Source::Flag),
1224 (
1225 config.and_then(|c| c.profile.release.registry),
1226 Source::Config,
1227 ),
1228 (
1229 record.and_then(|r| r.profile.release.registry),
1230 Source::Record,
1231 ),
1232 (None, Source::Observation),
1233 (None, Source::Default),
1234 ])
1235 .map_or((None, Source::Default), |(value, source)| {
1236 (Some(value), source)
1237 });
1238
1239 let build_key = |flag: Option<&str>, read: fn(&BuildAnswer) -> Option<String>| {
1245 answered([
1246 (flag.map(str::to_owned), Source::Flag),
1247 (
1248 config.and_then(|c| c.profile.release.build.as_ref().and_then(read)),
1249 Source::Config,
1250 ),
1251 (
1252 record.and_then(|r| r.profile.release.build.as_ref().and_then(read)),
1253 Source::Record,
1254 ),
1255 (None, Source::Observation),
1256 (None, Source::Default),
1257 ])
1258 .map_or((None, Source::Default), |(value, source)| {
1259 (Some(value), source)
1260 })
1261 };
1262 let (build_packages, packages_source) =
1263 build_key(flags.build_packages, |build| build.packages.clone());
1264 let (build_verify, verify_source) = build_key(None, |build| build.verify.clone());
1265 if let Some(packages) = &build_packages {
1266 crate::config::build_packages_shape(packages).map_err(crate::config::invalid)?;
1267 }
1268 if let Some(verify) = &build_verify {
1269 crate::config::build_verify_shape(verify).map_err(crate::config::invalid)?;
1270 }
1271 let pattern_key = |read: fn(&BuildAnswer) -> Option<Vec<String>>| {
1274 answered([
1275 (None, Source::Flag),
1276 (
1277 config.and_then(|c| c.profile.release.build.as_ref().and_then(read)),
1278 Source::Config,
1279 ),
1280 (
1281 record.and_then(|r| r.profile.release.build.as_ref().and_then(read)),
1282 Source::Record,
1283 ),
1284 (None, Source::Observation),
1285 (None, Source::Default),
1286 ])
1287 .map_or((None, Source::Default), |(value, source)| {
1288 (Some(value), source)
1289 })
1290 };
1291 let (build_include, include_source) = pattern_key(|build| build.include.clone());
1292 let (build_exclude, exclude_source) = pattern_key(|build| build.exclude.clone());
1293 for (key, patterns) in [
1294 ("profile.release.build.include", &build_include),
1295 ("profile.release.build.exclude", &build_exclude),
1296 ] {
1297 if let Some(patterns) = patterns {
1298 crate::config::build_patterns_shape(key, patterns).map_err(crate::config::invalid)?;
1299 }
1300 }
1301 let build = (build_packages.is_some()
1302 || build_include.is_some()
1303 || build_exclude.is_some()
1304 || build_verify.is_some())
1305 .then_some(BuildAnswer {
1306 command: None,
1307 packages: build_packages,
1308 include: build_include,
1309 exclude: build_exclude,
1310 verify: build_verify,
1311 });
1312
1313 let reporting = purpose == Purpose::Preview;
1318 let release = match mode {
1319 ReleaseMode::Automatic => {
1320 if let Some(Proposal::Ambiguous { drivers }) = &proposal
1321 && driver.is_none()
1322 && !reporting
1323 {
1324 return Err(RkError::Usage(format!(
1325 "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1326 drivers.join(" and "),
1327 crate::config::CONFIG_PATH
1328 )));
1329 }
1330 if forge.is_none() && !reporting {
1331 let message = observed.host.map_or_else(
1332 || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1333 |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1334 );
1335 return Err(RkError::refusal(
1336 Diagnostic::new(Reason::ForgeUndetected, message)
1337 .expected("a github.com or gitlab remote, or --forge")
1338 .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1339 ));
1340 }
1341 if driver.is_none() && !reporting {
1342 return Err(invalid_release(format!(
1343 "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1344 known_drivers.join("|")
1345 )));
1346 }
1347 if let Some(driver) = &driver
1348 && !technologies.contains(driver)
1349 && !reporting
1350 {
1351 return Err(invalid_release(format!(
1352 "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1353 if technologies.is_empty() {
1354 "empty".to_owned()
1355 } else {
1356 technologies.join(", ")
1357 }
1358 )));
1359 }
1360 let style = match (style, purpose) {
1361 (None, Purpose::Upgrade | Purpose::Adopt) => {
1362 return Err(RkError::Usage(
1363 "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1364 ));
1365 }
1366 (None, _) => Style::Trunk,
1367 (Some(style), _) => style,
1368 };
1369 if let Some(answer) = registry
1372 && (driver.is_some() || !reporting)
1373 {
1374 catalog::registry_answer_fits(mode, driver.as_deref(), answer)
1375 .map_err(RkError::Usage)?;
1376 }
1377 if build.is_some() && (driver.is_some() || !reporting) {
1378 catalog::build_answer_fits(mode, driver.as_deref()).map_err(RkError::Usage)?;
1379 }
1380 sources.insert("profile.release.driver", driver_source);
1381 sources.insert("profile.release.style", style_source);
1382 sources.insert("profile.release.line_prefix", prefix_source);
1383 if driver
1384 .as_deref()
1385 .and_then(RegistryAnswer::default_for)
1386 .is_some()
1387 {
1388 sources.insert("profile.release.registry", registry_source);
1389 }
1390 if driver.as_deref() == Some(BuildAnswer::DRIVER) {
1391 sources.insert("profile.release.build.packages", packages_source);
1392 sources.insert("profile.release.build.include", include_source);
1393 sources.insert("profile.release.build.exclude", exclude_source);
1394 sources.insert("profile.release.build.verify", verify_source);
1395 }
1396 ReleaseIntent {
1397 mode,
1398 driver,
1399 style: Some(style),
1400 line_prefix: Some(
1401 line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1402 ),
1403 registry,
1404 build,
1405 }
1406 }
1407 ReleaseMode::External | ReleaseMode::None => {
1408 for (key, present, value_source) in [
1415 ("profile.release.driver", driver.is_some(), driver_source),
1416 ("profile.release.style", style.is_some(), style_source),
1417 (
1418 "profile.release.line_prefix",
1419 line_prefix.is_some(),
1420 prefix_source,
1421 ),
1422 (
1423 "profile.release.registry",
1424 registry.is_some(),
1425 registry_source,
1426 ),
1427 (
1428 "profile.release.build.packages",
1429 build.as_ref().is_some_and(|build| build.packages.is_some()),
1430 packages_source,
1431 ),
1432 (
1433 "profile.release.build.include",
1434 build.as_ref().is_some_and(|build| build.include.is_some()),
1435 include_source,
1436 ),
1437 (
1438 "profile.release.build.exclude",
1439 build.as_ref().is_some_and(|build| build.exclude.is_some()),
1440 exclude_source,
1441 ),
1442 (
1443 "profile.release.build.verify",
1444 build.as_ref().is_some_and(|build| build.verify.is_some()),
1445 verify_source,
1446 ),
1447 ] {
1448 if present
1449 && matches!(value_source, Source::Flag | Source::Config)
1450 && value_source.rank() <= mode_source.rank()
1451 {
1452 return Err(invalid_release(format!(
1453 "{key} is set while profile.release.mode is {}",
1454 mode.as_str()
1455 )));
1456 }
1457 }
1458 ReleaseIntent {
1459 mode,
1460 driver: None,
1461 style: None,
1462 line_prefix: None,
1463 registry: None,
1464 build: None,
1465 }
1466 }
1467 };
1468
1469 let adapter_known = forge
1473 .as_deref()
1474 .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1475 let repo = match (forge.is_some(), adapter_known, repo) {
1476 (false, _, _) => String::new(),
1479 (true, false, repo) => repo.unwrap_or_default(),
1480 (true, true, Some(repo)) => repo,
1481 (true, true, None) if purpose == Purpose::Preview => {
1482 crate::projection::REPO_PLACEHOLDER.to_owned()
1483 }
1484 (true, true, None) => return Err(crate::landing::repo_unresolved()),
1485 };
1486
1487 let (trunk, source) = answered([
1489 (flags.trunk.map(str::to_owned), Source::Flag),
1490 (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1491 (record.map(|r| r.git.trunk.clone()), Source::Record),
1492 (None, Source::Observation),
1493 (
1494 Some(crate::config::TRUNK_DEFAULT.to_owned()),
1495 Source::Default,
1496 ),
1497 ])
1498 .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1499 sources.insert("git.trunk", source);
1500 let (checkout_mode, source) = answered([
1501 (flags.checkout_mode, Source::Flag),
1502 (config.and_then(|c| c.git.checkout_mode), Source::Config),
1503 (record.map(|r| r.git.checkout_mode), Source::Record),
1504 (None, Source::Observation),
1505 (
1506 Some(if purpose == Purpose::Adopt {
1507 CheckoutMode::MainWorktree
1508 } else {
1509 CheckoutMode::LinkedWorktree
1510 }),
1511 Source::Default,
1512 ),
1513 ])
1514 .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1515 sources.insert("git.checkout_mode", source);
1516 let (integration, source) = answered([
1521 (flags.integration, Source::Flag),
1522 (config.and_then(|c| c.git.integration), Source::Config),
1523 (record.map(|r| r.git.integration), Source::Record),
1524 (None, Source::Observation),
1525 (
1530 Some(if purpose == Purpose::Adopt {
1531 Integration::Forge
1532 } else {
1533 Integration::Local
1534 }),
1535 Source::Default,
1536 ),
1537 ])
1538 .unwrap_or((Integration::Local, Source::Default));
1539 sources.insert("git.integration", source);
1540 let (merge_form, source) = answered([
1544 (flags.merge_form, Source::Flag),
1545 (config.and_then(|c| c.git.merge_form), Source::Config),
1546 (record.map(|r| r.git.merge_form), Source::Record),
1547 (None, Source::Observation),
1548 (Some(manifest::merge_form_squash()), Source::Default),
1549 ])
1550 .unwrap_or((MergeForm::Squash, Source::Default));
1551 sources.insert("git.merge_form", source);
1552
1553 let (nix_packaging, source) = answered([
1555 (flags.nix, Source::Flag),
1556 (
1557 config.and_then(|c| c.capabilities.nix_packaging),
1558 Source::Config,
1559 ),
1560 (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1561 (None, Source::Observation),
1562 (Some(false), Source::Default),
1563 ])
1564 .unwrap_or((false, Source::Default));
1565 sources.insert("capabilities.nix_packaging", source);
1566 let (reporting_policy, source) = answered([
1567 (flags.reporting_policy, Source::Flag),
1568 (
1569 config.and_then(|c| c.capabilities.reporting_policy),
1570 Source::Config,
1571 ),
1572 (
1573 record.map(|r| r.capabilities.reporting_policy),
1574 Source::Record,
1575 ),
1576 (None, Source::Observation),
1577 (
1580 Some(release.mode == ReleaseMode::Automatic),
1581 Source::Default,
1582 ),
1583 ])
1584 .unwrap_or((false, Source::Default));
1585 sources.insert("capabilities.reporting_policy", source);
1586 let (scorecard, source) = answered([
1587 (flags.scorecard, Source::Flag),
1588 (
1589 config.and_then(|c| c.capabilities.scorecard),
1590 Source::Config,
1591 ),
1592 (record.map(|r| r.capabilities.scorecard), Source::Record),
1593 (None, Source::Observation),
1594 (Some(false), Source::Default),
1595 ])
1596 .unwrap_or((false, Source::Default));
1597 sources.insert("capabilities.scorecard", source);
1598 let configured_scanning = config
1599 .and_then(|c| c.capabilities.code_scanning.as_deref())
1600 .map(Provider::parse)
1601 .transpose()?;
1602 let (code_scanning, source) = answered([
1603 (flags.code_scanning, Source::Flag),
1604 (configured_scanning, Source::Config),
1605 (record.map(|r| r.capabilities.code_scanning), Source::Record),
1606 (None, Source::Observation),
1607 (Some(None), Source::Default),
1608 ])
1609 .unwrap_or((None, Source::Default));
1610 sources.insert("capabilities.code_scanning", source);
1611 let (workflow_guards, source) = answered([
1616 (flags.workflow_guards, Source::Flag),
1617 (
1618 config.and_then(|c| c.capabilities.workflow_guards),
1619 Source::Config,
1620 ),
1621 (
1622 record.map(|r| r.capabilities.workflow_guards()),
1623 Source::Record,
1624 ),
1625 (None, Source::Observation),
1626 (Some(false), Source::Default),
1627 ])
1628 .unwrap_or((false, Source::Default));
1629 sources.insert("capabilities.workflow_guards", source);
1630 let (security_contact, source) = answered([
1638 (None, Source::Flag),
1639 (
1640 config.and_then(|c| c.security.contact.clone()),
1641 Source::Config,
1642 ),
1643 (
1644 record.map(|r| r.parameters.security_contact.clone()),
1645 Source::Record,
1646 ),
1647 (None, Source::Observation),
1648 (Some(String::new()), Source::Default),
1649 ])
1650 .unwrap_or((String::new(), Source::Default));
1651 let security_contact =
1652 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1653 sources.insert("security.contact", source);
1654 let (security_response, source) = answered([
1655 (None, Source::Flag),
1656 (
1657 config.and_then(|c| c.security.response.clone()),
1658 Source::Config,
1659 ),
1660 (
1661 record.map(|r| r.parameters.security_response.clone()),
1662 Source::Record,
1663 ),
1664 (None, Source::Observation),
1665 (
1666 Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1667 Source::Default,
1668 ),
1669 ])
1670 .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1671 let security_response =
1672 crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1673 sources.insert("security.response", source);
1674 let (security_scope, source) = answered([
1675 (None, Source::Flag),
1676 (
1677 config.and_then(|c| c.security.scope.clone()),
1678 Source::Config,
1679 ),
1680 (
1681 record.map(|r| r.parameters.security_scope.clone()),
1682 Source::Record,
1683 ),
1684 (None, Source::Observation),
1685 (Some(String::new()), Source::Default),
1686 ])
1687 .unwrap_or((String::new(), Source::Default));
1688 let security_scope =
1689 crate::config::canonical_scope(&security_scope).map_err(crate::config::invalid)?;
1690 sources.insert("security.scope", source);
1691
1692 let gate_default = gate_defaults(&ProfileSnapshot {
1696 technologies: technologies.clone(),
1697 forge: forge.clone(),
1698 release: release.clone(),
1699 });
1700 let (required_check, source) = answered([
1701 (flags.required_check.map(str::to_owned), Source::Flag),
1702 (
1703 config
1704 .map(|c| c.setup.required_check.clone())
1705 .filter(|name| !name.is_empty()),
1706 Source::Config,
1707 ),
1708 (
1709 record
1710 .map(|r| r.parameters.required_check.clone())
1711 .filter(|name| !name.is_empty()),
1712 Source::Record,
1713 ),
1714 (None, Source::Observation),
1715 (
1716 Some(gate_default.map_or_else(String::new, str::to_owned)),
1717 Source::Default,
1718 ),
1719 ])
1720 .unwrap_or((String::new(), Source::Default));
1721 sources.insert("setup.required_check", source);
1722 let recorded = || {
1726 record
1727 .and_then(|r| r.parameters.dev_environment.clone())
1728 .map_or((None, Source::Default), |answer| {
1729 (Some(answer), Source::Record)
1730 })
1731 };
1732 let (dev_environment, source) = config
1733 .and_then(|c| c.dev_environment.clone())
1734 .map_or_else(recorded, |answer| (answer.answered(), Source::Config));
1735 if let Some(answer) = &dev_environment {
1736 answer
1737 .check_forge(forge.as_deref())
1738 .map_err(crate::config::invalid)?;
1739 }
1740 sources.insert("dev_environment", source);
1741 if forge.as_deref() == Some("gitlab") && flags.required_check.is_some() {
1746 return Err(RkError::Usage(
1747 "--required-check is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check".to_owned(),
1748 ));
1749 }
1750
1751 let mut unknown: Vec<String> = technologies
1752 .iter()
1753 .filter(|name| !known_drivers.contains(name))
1754 .map(|name| format!("technology {name}"))
1755 .collect();
1756 if let Some(name) = &forge
1757 && crate::detect::Forge::parse(name).is_none()
1758 {
1759 unknown.push(format!("forge {name}"));
1760 }
1761
1762 let (bot_name, source) = config
1765 .map(|c| c.setup.bot.name.trim().to_owned())
1766 .filter(|name| !name.is_empty())
1767 .map_or((String::new(), Source::Default), |name| {
1768 (name, Source::Config)
1769 });
1770 sources.insert("setup.bot.name", source);
1771
1772 Ok(Resolved {
1773 params: Params {
1774 profile: ProfileSnapshot {
1775 technologies,
1776 forge,
1777 release,
1778 },
1779 git: GitWorkflow {
1780 trunk,
1781 checkout_mode,
1782 integration,
1783 merge_form,
1784 },
1785 capabilities: CapabilityRequests {
1786 nix_packaging,
1787 reporting_policy,
1788 scorecard,
1789 code_scanning,
1790 workflow_guards: Some(workflow_guards),
1791 },
1792 repo,
1793 security_contact,
1794 security_response,
1795 security_scope,
1796 required_check,
1797 dev_environment,
1798 },
1799 sources,
1800 unknown,
1801 proposal,
1802 bot_name,
1803 })
1804}