1pub mod catalog;
21
22use std::collections::BTreeMap;
23
24use camino::Utf8Path;
25use serde::{Deserialize, Serialize};
26
27use crate::diagnostic::{Diagnostic, Reason};
28use crate::error::RkError;
29use crate::landing::manifest::{self, CheckoutMode, Integration, MergeForm, Provider, Style};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ReleaseMode {
35 Automatic,
38 External,
41 None,
43}
44
45impl ReleaseMode {
46 #[must_use]
48 pub const fn as_str(self) -> &'static str {
49 match self {
50 Self::Automatic => "automatic",
51 Self::External => "external",
52 Self::None => "none",
53 }
54 }
55
56 pub fn parse(raw: &str) -> Result<Self, RkError> {
62 match raw {
63 "automatic" => Ok(Self::Automatic),
64 "external" => Ok(Self::External),
65 "none" => Ok(Self::None),
66 other => Err(RkError::Usage(format!(
67 "unknown release mode '{other}'; the modes are: automatic, external, none"
68 ))),
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct ReleaseIntent {
79 pub mode: ReleaseMode,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub driver: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub style: Option<Style>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub line_prefix: Option<String>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct ProfileSnapshot {
97 pub technologies: Vec<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub forge: Option<String>,
102 pub release: ReleaseIntent,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct GitWorkflow {
109 pub trunk: String,
111 pub checkout_mode: CheckoutMode,
113 #[serde(default = "crate::landing::manifest::integration_forge")]
116 pub integration: Integration,
117 #[serde(default = "crate::landing::manifest::merge_form_squash")]
120 pub merge_form: MergeForm,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct CapabilityRequests {
126 #[serde(default)]
128 pub nix_packaging: bool,
129 #[serde(default)]
131 pub reporting_policy: bool,
132 #[serde(default)]
134 pub scorecard: bool,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub code_scanning: Option<Provider>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub workflow_guards: Option<bool>,
144}
145
146impl CapabilityRequests {
147 #[must_use]
151 pub fn workflow_guards(&self) -> bool {
152 self.workflow_guards.unwrap_or(true)
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
158#[serde(rename_all = "lowercase")]
159pub enum Source {
160 Flag,
162 Config,
164 Record,
166 Observation,
168 Default,
170}
171
172impl Source {
173 #[must_use]
179 pub const fn rank(self) -> u8 {
180 match self {
181 Self::Flag => 0,
182 Self::Config => 1,
183 Self::Record => 2,
184 Self::Observation => 3,
185 Self::Default => 4,
186 }
187 }
188
189 #[must_use]
191 pub const fn as_str(self) -> &'static str {
192 match self {
193 Self::Flag => "flag",
194 Self::Config => "config",
195 Self::Record => "record",
196 Self::Observation => "observation",
197 Self::Default => "default",
198 }
199 }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
205#[serde(rename_all = "kebab-case", tag = "state")]
206pub enum Proposal {
207 None,
209 Automatic {
211 driver: String,
213 },
214 Ambiguous {
217 drivers: Vec<String>,
219 },
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct Params {
228 profile: ProfileSnapshot,
229 git: GitWorkflow,
230 capabilities: CapabilityRequests,
231 repo: String,
232 security_contact: String,
233 security_response: String,
234 required_check: String,
235 required_workflow: String,
236}
237
238#[derive(Default)]
240pub struct Inputs<'a> {
241 pub technologies: &'a [String],
244 pub forge: Option<&'a str>,
246 pub repo: Option<&'a str>,
248 pub release_mode: Option<ReleaseMode>,
250 pub release_driver: Option<&'a str>,
252 pub style: Option<Style>,
254 pub trunk: Option<&'a str>,
256 pub checkout_mode: Option<CheckoutMode>,
258 pub integration: Option<Integration>,
260 pub merge_form: Option<MergeForm>,
262 pub required_check: Option<&'a str>,
264 pub required_workflow: Option<&'a str>,
267 pub nix: Option<bool>,
269 pub reporting_policy: Option<bool>,
271 pub scorecard: Option<bool>,
273 pub code_scanning: Option<Option<Provider>>,
276 pub workflow_guards: Option<bool>,
278}
279
280#[derive(Clone, Copy, PartialEq, Eq)]
282pub enum Purpose {
283 Init,
285 Preview,
288 Upgrade,
290 Adopt,
292}
293
294#[derive(Debug, Clone)]
296pub struct Resolved {
297 pub params: Params,
299 pub sources: BTreeMap<&'static str, Source>,
301 pub unknown: Vec<String>,
303 pub proposal: Option<Proposal>,
306}
307
308pub fn canonical_category(raw: &str) -> Result<String, String> {
318 let lowered = raw.trim().to_ascii_lowercase();
319 let shaped = lowered
320 .chars()
321 .next()
322 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
323 && lowered
324 .chars()
325 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
326 if shaped {
327 Ok(lowered)
328 } else {
329 Err(format!(
330 "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
331 ))
332 }
333}
334
335pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
341 let mut out = Vec::with_capacity(raw.len());
342 for value in raw {
343 let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
344 if out.contains(&name) {
345 return Err(format!("{key} names {name} twice; each technology once"));
346 }
347 out.push(name);
348 }
349 out.sort();
350 Ok(out)
351}
352
353fn push_shell_word(out: &mut String, value: &str) {
356 let inert = !value.is_empty()
357 && value.bytes().all(|byte| {
358 byte.is_ascii_alphanumeric()
359 || matches!(
360 byte,
361 b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
362 )
363 });
364 if inert {
365 out.push_str(value);
366 return;
367 }
368 out.push('\'');
369 out.push_str(&value.replace('\'', "'\"'\"'"));
370 out.push('\'');
371}
372
373impl Params {
374 #[must_use]
377 pub fn from_record(record: &manifest::Manifest) -> Self {
378 Self {
379 profile: record.profile.clone(),
380 git: record.git.clone(),
381 capabilities: record.capabilities.clone(),
382 repo: record.parameters.repo.clone(),
383 security_contact: record.parameters.security_contact.clone(),
384 security_response: record.parameters.security_response.clone(),
385 required_check: gate_answer(
388 record,
389 |(check, _)| check,
390 |parameters| ¶meters.required_check,
391 ),
392 required_workflow: gate_answer(
393 record,
394 |(_, workflow)| workflow,
395 |parameters| ¶meters.required_workflow,
396 ),
397 }
398 }
399
400 pub fn resolve(
408 target: &Utf8Path,
409 flags: &Inputs<'_>,
410 config: Option<&crate::config::Config>,
411 record: Option<&manifest::Manifest>,
412 purpose: Purpose,
413 ) -> Result<Self, RkError> {
414 resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
415 }
416
417 #[must_use]
419 pub const fn profile(&self) -> &ProfileSnapshot {
420 &self.profile
421 }
422
423 #[must_use]
425 pub const fn git(&self) -> &GitWorkflow {
426 &self.git
427 }
428
429 #[must_use]
431 pub const fn capabilities(&self) -> &CapabilityRequests {
432 &self.capabilities
433 }
434
435 #[must_use]
437 pub fn technologies(&self) -> &[String] {
438 &self.profile.technologies
439 }
440
441 #[must_use]
443 pub fn forge(&self) -> Option<&str> {
444 self.profile.forge.as_deref()
445 }
446
447 #[must_use]
449 pub const fn release_mode(&self) -> ReleaseMode {
450 self.profile.release.mode
451 }
452
453 #[must_use]
455 pub fn driver(&self) -> Option<&str> {
456 self.profile.release.driver.as_deref()
457 }
458
459 #[must_use]
461 pub const fn style(&self) -> Option<Style> {
462 self.profile.release.style
463 }
464
465 #[must_use]
467 pub const fn nix_packaging(&self) -> bool {
468 self.capabilities.nix_packaging
469 }
470
471 #[must_use]
473 pub const fn reporting_policy(&self) -> bool {
474 self.capabilities.reporting_policy
475 }
476
477 #[must_use]
479 pub const fn scorecard(&self) -> bool {
480 self.capabilities.scorecard
481 }
482
483 #[must_use]
485 pub const fn code_scanning(&self) -> Option<Provider> {
486 self.capabilities.code_scanning
487 }
488
489 #[must_use]
491 pub fn workflow_guards(&self) -> bool {
492 self.capabilities.workflow_guards()
493 }
494
495 #[must_use]
498 pub fn repo(&self) -> &str {
499 &self.repo
500 }
501
502 #[must_use]
504 pub const fn checkout_mode(&self) -> CheckoutMode {
505 self.git.checkout_mode
506 }
507
508 #[must_use]
510 pub const fn integration(&self) -> Integration {
511 self.git.integration
512 }
513
514 #[must_use]
516 pub const fn merge_form(&self) -> MergeForm {
517 self.git.merge_form
518 }
519
520 #[must_use]
522 pub fn trunk(&self) -> &str {
523 &self.git.trunk
524 }
525
526 #[must_use]
529 pub fn line_prefix(&self) -> &str {
530 self.profile
531 .release
532 .line_prefix
533 .as_deref()
534 .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
535 }
536
537 #[must_use]
540 pub fn security_contact(&self) -> &str {
541 &self.security_contact
542 }
543
544 #[must_use]
546 pub fn security_response(&self) -> &str {
547 &self.security_response
548 }
549
550 #[must_use]
553 pub fn required_check(&self) -> &str {
554 &self.required_check
555 }
556
557 #[must_use]
560 pub fn required_workflow(&self) -> &str {
561 &self.required_workflow
562 }
563
564 #[must_use]
568 pub fn canonical_flags(&self) -> String {
569 let mut out = String::new();
570 for technology in &self.profile.technologies {
571 out.push_str(" --technology ");
572 out.push_str(technology);
573 }
574 if let Some(forge) = &self.profile.forge {
575 out.push_str(" --forge ");
576 out.push_str(forge);
577 }
578 if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
582 out.push_str(" --repo ");
583 out.push_str(&self.repo);
584 }
585 out.push_str(" --release-mode ");
586 out.push_str(self.profile.release.mode.as_str());
587 if let Some(driver) = &self.profile.release.driver {
588 out.push_str(" --release-driver ");
589 out.push_str(driver);
590 }
591 if let Some(style) = self.profile.release.style {
592 out.push_str(" --release-style ");
593 out.push_str(style.as_str());
594 }
595 out.push_str(" --trunk ");
596 out.push_str(&self.git.trunk);
597 out.push_str(" --checkout-mode ");
598 out.push_str(self.git.checkout_mode.as_str());
599 out.push_str(" --integration ");
600 out.push_str(self.git.integration.as_str());
601 out.push_str(" --merge-form ");
602 out.push_str(self.git.merge_form.as_str());
603 if !self.required_check.is_empty() {
607 out.push_str(" --required-check ");
608 push_shell_word(&mut out, &self.required_check);
609 }
610 if !self.required_workflow.is_empty() {
611 out.push_str(" --required-workflow ");
612 push_shell_word(&mut out, &self.required_workflow);
613 }
614 out
615 }
616
617 #[must_use]
623 pub fn capability_flags(&self) -> String {
624 let mut out = String::new();
625 if self.capabilities.nix_packaging {
626 out.push_str(" --nix-packaging");
627 }
628 if self.capabilities.reporting_policy {
629 out.push_str(" --reporting-policy");
630 }
631 if self.capabilities.scorecard {
632 out.push_str(" --scorecard");
633 }
634 if self.workflow_guards() {
635 out.push_str(" --workflow-guards");
636 }
637 out.push_str(" --code-scanning ");
644 out.push_str(
645 self.capabilities
646 .code_scanning
647 .map_or("off", Provider::as_str),
648 );
649 out
650 }
651
652 #[must_use]
659 pub fn capability_toggles(&self) -> String {
660 let word = |on: bool| if on { "on" } else { "off" };
661 format!(
662 " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {} --workflow-guards {}",
663 word(self.capabilities.nix_packaging),
664 word(self.capabilities.reporting_policy),
665 word(self.capabilities.scorecard),
666 self.capabilities
667 .code_scanning
668 .map_or("off", Provider::as_str),
669 word(self.workflow_guards())
670 )
671 }
672}
673
674#[cfg(test)]
675impl Params {
676 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
681 Self {
682 profile: ProfileSnapshot {
683 technologies: vec!["rust".to_owned()],
684 forge: Some("github".to_owned()),
685 release: ReleaseIntent {
686 mode: ReleaseMode::Automatic,
687 driver: Some("rust".to_owned()),
688 style,
689 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
690 },
691 },
692 git: GitWorkflow {
693 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
694 checkout_mode: CheckoutMode::LinkedWorktree,
695 integration: Integration::Local,
696 merge_form: MergeForm::Squash,
697 },
698 capabilities: CapabilityRequests {
699 nix_packaging: false,
700 reporting_policy: true,
701 scorecard: false,
702 code_scanning: None,
703 workflow_guards: Some(true),
704 },
705 repo: repo.to_owned(),
706 security_contact: String::new(),
707 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
708 required_check: "gate".to_owned(),
709 required_workflow: "ci".to_owned(),
710 }
711 }
712
713 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
715 Self {
716 security_contact: contact.to_owned(),
717 security_response: response.to_owned(),
718 ..Self::for_test("acme/widget", Some(Style::Trunk))
719 }
720 }
721
722 pub(crate) fn for_test_release_less(
725 technologies: &[&str],
726 forge: Option<&str>,
727 mode: ReleaseMode,
728 ) -> Self {
729 let mut params = Self::for_test("acme/widget", None);
730 params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
731 params.profile.forge = forge.map(str::to_owned);
732 params.profile.release = ReleaseIntent {
733 mode,
734 driver: None,
735 style: None,
736 line_prefix: None,
737 };
738 params.capabilities.reporting_policy = false;
739 if forge.is_none() {
740 params.repo = String::new();
741 }
742 params
743 }
744
745 pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
747 self.profile.technologies = vec![driver.to_owned()];
748 self.profile.release.driver = Some(driver.to_owned());
749 self.profile.forge = Some(forge.to_owned());
750 }
751
752 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
754 self.git.checkout_mode = mode;
755 }
756
757 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
759 self.git.integration = mode;
760 }
761
762 pub(crate) const fn set_merge_form_for_test(&mut self, form: MergeForm) {
764 self.git.merge_form = form;
765 }
766
767 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
769 self.capabilities.nix_packaging = nix;
770 }
771
772 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
774 self.capabilities.scorecard = scorecard;
775 }
776
777 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
779 self.capabilities.code_scanning = provider;
780 }
781
782 pub(crate) const fn set_workflow_guards_for_test(&mut self, on: bool) {
784 self.capabilities.workflow_guards = Some(on);
785 }
786}
787
788fn invalid_release(message: impl std::fmt::Display) -> RkError {
791 RkError::Usage(format!(
792 "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
793 ))
794}
795
796#[must_use]
818pub fn gate_defaults(
819 profile: &ProfileSnapshot,
820 git: &GitWorkflow,
821) -> Option<(&'static str, &'static str)> {
822 let consuming = profile.forge.as_deref() == Some("github")
823 && profile.release.mode == ReleaseMode::Automatic
824 && profile.release.style == Some(Style::Trunk)
825 && git.integration == Integration::Local;
826 consuming.then_some(("gate", "ci"))
827}
828
829fn gate_answer(
832 record: &manifest::Manifest,
833 pick: impl Fn((&'static str, &'static str)) -> &'static str,
834 held: impl Fn(&manifest::Parameters) -> &String,
835) -> String {
836 let recorded = held(&record.parameters);
837 if recorded.is_empty() {
838 gate_defaults(&record.profile, &record.git)
839 .map_or_else(String::new, |pair| pick(pair).to_owned())
840 } else {
841 recorded.clone()
842 }
843}
844
845fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
847 chain
848 .into_iter()
849 .find_map(|(value, source)| value.map(|value| (value, source)))
850}
851
852#[allow(
860 clippy::too_many_lines,
861 reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
862)]
863pub fn resolve(
864 target: &Utf8Path,
865 flags: &Inputs<'_>,
866 config: Option<&crate::config::Config>,
867 record: Option<&manifest::Manifest>,
868 purpose: Purpose,
869) -> Result<Resolved, RkError> {
870 let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
871 let observed = crate::detect::observe(target.as_std_path());
872 let known_drivers = catalog::known_drivers();
873
874 let (technologies, source) = answered([
876 (
877 (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
878 Source::Flag,
879 ),
880 (
881 config.and_then(|c| c.profile.technologies.clone()),
882 Source::Config,
883 ),
884 (
885 record.map(|r| r.profile.technologies.clone()),
886 Source::Record,
887 ),
888 (
889 Some(
890 observed
891 .technologies
892 .iter()
893 .map(|t| (*t).to_owned())
894 .collect(),
895 ),
896 Source::Observation,
897 ),
898 (None, Source::Default),
899 ])
900 .unwrap_or_else(|| (Vec::new(), Source::Default));
901 let technologies =
902 canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
903 sources.insert("profile.technologies", source);
904
905 let forge_flag = flags
907 .forge
908 .map(|name| canonical_category(name).map_err(RkError::Usage))
909 .transpose()?;
910 let (forge, source) = answered([
911 (forge_flag.map(Some), Source::Flag),
912 (
913 config
914 .and_then(|c| c.profile.forge.clone())
915 .map(|value| if value.is_empty() { None } else { Some(value) }),
916 Source::Config,
917 ),
918 (record.map(|r| r.profile.forge.clone()), Source::Record),
919 (
920 observed.forge.map(|forge| Some(forge.as_str().to_owned())),
921 Source::Observation,
922 ),
923 (Some(None), Source::Default),
924 ])
925 .unwrap_or((None, Source::Default));
926 let forge = forge
927 .map(|name| canonical_category(&name).map_err(RkError::Usage))
928 .transpose()?;
929 sources.insert("profile.forge", source);
930
931 let (repo, source) = answered([
933 (flags.repo.map(str::to_owned), Source::Flag),
934 (
935 config
936 .map(|c| c.project.repo.clone())
937 .filter(|value| !value.is_empty()),
938 Source::Config,
939 ),
940 (
941 record
942 .map(|r| r.parameters.repo.clone())
943 .filter(|value| !value.is_empty()),
944 Source::Record,
945 ),
946 (observed.repo.clone(), Source::Observation),
947 (None, Source::Default),
948 ])
949 .map_or((None, Source::Default), |(value, source)| {
950 (Some(value), source)
951 });
952 sources.insert("project.repo", source);
953
954 let release_bearing: Vec<String> = technologies
957 .iter()
958 .filter(|name| known_drivers.contains(name))
959 .cloned()
960 .collect();
961 let proposal = match release_bearing.as_slice() {
962 [] => Proposal::None,
963 [one] => Proposal::Automatic {
964 driver: one.clone(),
965 },
966 many => Proposal::Ambiguous {
967 drivers: many.to_vec(),
968 },
969 };
970 let proposed_mode = match &proposal {
977 Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
978 Proposal::None => ReleaseMode::None,
979 };
980 let (mode, source) = answered([
981 (flags.release_mode, Source::Flag),
982 (config.and_then(|c| c.profile.release.mode), Source::Config),
983 (record.map(|r| r.profile.release.mode), Source::Record),
984 (Some(proposed_mode), Source::Observation),
985 (None, Source::Default),
986 ])
987 .unwrap_or((ReleaseMode::None, Source::Default));
988 let mode_source = source;
989 sources.insert("profile.release.mode", mode_source);
990 let mode_answered_above = mode_source != Source::Observation;
991 let proposal = (!mode_answered_above).then_some(proposal);
992
993 let driver_flag = flags
997 .release_driver
998 .map(|name| canonical_category(name).map_err(RkError::Usage))
999 .transpose()?;
1000 let (driver, driver_source) = answered([
1001 (driver_flag, Source::Flag),
1002 (
1003 config.and_then(|c| c.profile.release.driver.clone()),
1004 Source::Config,
1005 ),
1006 (
1007 record.and_then(|r| r.profile.release.driver.clone()),
1008 Source::Record,
1009 ),
1010 (
1011 match &proposal {
1012 Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
1013 Some(driver.clone())
1014 }
1015 _ => None,
1016 },
1017 Source::Observation,
1018 ),
1019 (None, Source::Default),
1020 ])
1021 .map_or((None, Source::Default), |(value, source)| {
1022 (Some(value), source)
1023 });
1024 let (style, style_source) = answered([
1025 (flags.style, Source::Flag),
1026 (config.and_then(|c| c.profile.release.style), Source::Config),
1027 (record.and_then(|r| r.profile.release.style), Source::Record),
1028 (None, Source::Observation),
1029 (
1030 (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
1031 .then_some(Style::Trunk),
1032 Source::Default,
1033 ),
1034 ])
1035 .map_or((None, Source::Default), |(value, source)| {
1036 (Some(value), source)
1037 });
1038 let (line_prefix, prefix_source) = answered([
1039 (None, Source::Flag),
1040 (
1041 config.and_then(|c| c.profile.release.line_prefix.clone()),
1042 Source::Config,
1043 ),
1044 (
1045 record.and_then(|r| r.profile.release.line_prefix.clone()),
1046 Source::Record,
1047 ),
1048 (None, Source::Observation),
1049 (
1050 (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1051 Source::Default,
1052 ),
1053 ])
1054 .map_or((None, Source::Default), |(value, source)| {
1055 (Some(value), source)
1056 });
1057
1058 let reporting = purpose == Purpose::Preview;
1063 let release = match mode {
1064 ReleaseMode::Automatic => {
1065 if let Some(Proposal::Ambiguous { drivers }) = &proposal
1066 && driver.is_none()
1067 && !reporting
1068 {
1069 return Err(RkError::Usage(format!(
1070 "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1071 drivers.join(" and "),
1072 crate::config::CONFIG_PATH
1073 )));
1074 }
1075 if forge.is_none() && !reporting {
1076 let message = observed.host.map_or_else(
1077 || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1078 |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1079 );
1080 return Err(RkError::refusal(
1081 Diagnostic::new(Reason::ForgeUndetected, message)
1082 .expected("a github.com or gitlab remote, or --forge")
1083 .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1084 ));
1085 }
1086 if driver.is_none() && !reporting {
1087 return Err(invalid_release(format!(
1088 "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1089 known_drivers.join("|")
1090 )));
1091 }
1092 if let Some(driver) = &driver
1093 && !technologies.contains(driver)
1094 && !reporting
1095 {
1096 return Err(invalid_release(format!(
1097 "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1098 if technologies.is_empty() {
1099 "empty".to_owned()
1100 } else {
1101 technologies.join(", ")
1102 }
1103 )));
1104 }
1105 let style = match (style, purpose) {
1106 (None, Purpose::Upgrade | Purpose::Adopt) => {
1107 return Err(RkError::Usage(
1108 "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1109 ));
1110 }
1111 (None, _) => Style::Trunk,
1112 (Some(style), _) => style,
1113 };
1114 sources.insert("profile.release.driver", driver_source);
1115 sources.insert("profile.release.style", style_source);
1116 sources.insert("profile.release.line_prefix", prefix_source);
1117 ReleaseIntent {
1118 mode,
1119 driver,
1120 style: Some(style),
1121 line_prefix: Some(
1122 line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1123 ),
1124 }
1125 }
1126 ReleaseMode::External | ReleaseMode::None => {
1127 for (key, present, value_source) in [
1134 ("profile.release.driver", driver.is_some(), driver_source),
1135 ("profile.release.style", style.is_some(), style_source),
1136 (
1137 "profile.release.line_prefix",
1138 line_prefix.is_some(),
1139 prefix_source,
1140 ),
1141 ] {
1142 if present
1143 && matches!(value_source, Source::Flag | Source::Config)
1144 && value_source.rank() <= mode_source.rank()
1145 {
1146 return Err(invalid_release(format!(
1147 "{key} is set while profile.release.mode is {}",
1148 mode.as_str()
1149 )));
1150 }
1151 }
1152 ReleaseIntent {
1153 mode,
1154 driver: None,
1155 style: None,
1156 line_prefix: None,
1157 }
1158 }
1159 };
1160
1161 let adapter_known = forge
1165 .as_deref()
1166 .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1167 let repo = match (forge.is_some(), adapter_known, repo) {
1168 (false, _, _) => String::new(),
1171 (true, false, repo) => repo.unwrap_or_default(),
1172 (true, true, Some(repo)) => repo,
1173 (true, true, None) if purpose == Purpose::Preview => {
1174 crate::projection::REPO_PLACEHOLDER.to_owned()
1175 }
1176 (true, true, None) => return Err(crate::landing::repo_unresolved()),
1177 };
1178
1179 let (trunk, source) = answered([
1181 (flags.trunk.map(str::to_owned), Source::Flag),
1182 (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1183 (record.map(|r| r.git.trunk.clone()), Source::Record),
1184 (None, Source::Observation),
1185 (
1186 Some(crate::config::TRUNK_DEFAULT.to_owned()),
1187 Source::Default,
1188 ),
1189 ])
1190 .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1191 sources.insert("git.trunk", source);
1192 let (checkout_mode, source) = answered([
1193 (flags.checkout_mode, Source::Flag),
1194 (config.and_then(|c| c.git.checkout_mode), Source::Config),
1195 (record.map(|r| r.git.checkout_mode), Source::Record),
1196 (None, Source::Observation),
1197 (
1198 Some(if purpose == Purpose::Adopt {
1199 CheckoutMode::MainWorktree
1200 } else {
1201 CheckoutMode::LinkedWorktree
1202 }),
1203 Source::Default,
1204 ),
1205 ])
1206 .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1207 sources.insert("git.checkout_mode", source);
1208 let (integration, source) = answered([
1213 (flags.integration, Source::Flag),
1214 (config.and_then(|c| c.git.integration), Source::Config),
1215 (record.map(|r| r.git.integration), Source::Record),
1216 (None, Source::Observation),
1217 (
1222 Some(if purpose == Purpose::Adopt {
1223 Integration::Forge
1224 } else {
1225 Integration::Local
1226 }),
1227 Source::Default,
1228 ),
1229 ])
1230 .unwrap_or((Integration::Local, Source::Default));
1231 sources.insert("git.integration", source);
1232 let (merge_form, source) = answered([
1236 (flags.merge_form, Source::Flag),
1237 (config.and_then(|c| c.git.merge_form), Source::Config),
1238 (record.map(|r| r.git.merge_form), Source::Record),
1239 (None, Source::Observation),
1240 (Some(manifest::merge_form_squash()), Source::Default),
1241 ])
1242 .unwrap_or((MergeForm::Squash, Source::Default));
1243 sources.insert("git.merge_form", source);
1244
1245 let (nix_packaging, source) = answered([
1247 (flags.nix, Source::Flag),
1248 (
1249 config.and_then(|c| c.capabilities.nix_packaging),
1250 Source::Config,
1251 ),
1252 (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1253 (None, Source::Observation),
1254 (Some(false), Source::Default),
1255 ])
1256 .unwrap_or((false, Source::Default));
1257 sources.insert("capabilities.nix_packaging", source);
1258 let (reporting_policy, source) = answered([
1259 (flags.reporting_policy, Source::Flag),
1260 (
1261 config.and_then(|c| c.capabilities.reporting_policy),
1262 Source::Config,
1263 ),
1264 (
1265 record.map(|r| r.capabilities.reporting_policy),
1266 Source::Record,
1267 ),
1268 (None, Source::Observation),
1269 (
1272 Some(release.mode == ReleaseMode::Automatic),
1273 Source::Default,
1274 ),
1275 ])
1276 .unwrap_or((false, Source::Default));
1277 sources.insert("capabilities.reporting_policy", source);
1278 let (scorecard, source) = answered([
1279 (flags.scorecard, Source::Flag),
1280 (
1281 config.and_then(|c| c.capabilities.scorecard),
1282 Source::Config,
1283 ),
1284 (record.map(|r| r.capabilities.scorecard), Source::Record),
1285 (None, Source::Observation),
1286 (Some(false), Source::Default),
1287 ])
1288 .unwrap_or((false, Source::Default));
1289 sources.insert("capabilities.scorecard", source);
1290 let configured_scanning = config
1291 .and_then(|c| c.capabilities.code_scanning.as_deref())
1292 .map(Provider::parse)
1293 .transpose()?;
1294 let (code_scanning, source) = answered([
1295 (flags.code_scanning, Source::Flag),
1296 (configured_scanning, Source::Config),
1297 (record.map(|r| r.capabilities.code_scanning), Source::Record),
1298 (None, Source::Observation),
1299 (Some(None), Source::Default),
1300 ])
1301 .unwrap_or((None, Source::Default));
1302 sources.insert("capabilities.code_scanning", source);
1303 let (workflow_guards, source) = answered([
1308 (flags.workflow_guards, Source::Flag),
1309 (
1310 config.and_then(|c| c.capabilities.workflow_guards),
1311 Source::Config,
1312 ),
1313 (
1314 record.map(|r| r.capabilities.workflow_guards()),
1315 Source::Record,
1316 ),
1317 (None, Source::Observation),
1318 (Some(false), Source::Default),
1319 ])
1320 .unwrap_or((false, Source::Default));
1321 sources.insert("capabilities.workflow_guards", source);
1322 let (security_contact, source) = answered([
1330 (None, Source::Flag),
1331 (
1332 config.and_then(|c| c.security.contact.clone()),
1333 Source::Config,
1334 ),
1335 (
1336 record.map(|r| r.parameters.security_contact.clone()),
1337 Source::Record,
1338 ),
1339 (None, Source::Observation),
1340 (Some(String::new()), Source::Default),
1341 ])
1342 .unwrap_or((String::new(), Source::Default));
1343 let security_contact =
1344 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1345 sources.insert("security.contact", source);
1346 let (security_response, source) = answered([
1347 (None, Source::Flag),
1348 (
1349 config.and_then(|c| c.security.response.clone()),
1350 Source::Config,
1351 ),
1352 (
1353 record.map(|r| r.parameters.security_response.clone()),
1354 Source::Record,
1355 ),
1356 (None, Source::Observation),
1357 (
1358 Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1359 Source::Default,
1360 ),
1361 ])
1362 .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1363 let security_response =
1364 crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1365 sources.insert("security.response", source);
1366
1367 let gate_default = gate_defaults(
1372 &ProfileSnapshot {
1373 technologies: technologies.clone(),
1374 forge: forge.clone(),
1375 release: release.clone(),
1376 },
1377 &GitWorkflow {
1378 trunk: trunk.clone(),
1379 checkout_mode,
1380 integration,
1381 merge_form,
1382 },
1383 );
1384 let (required_check, source) = answered([
1385 (flags.required_check.map(str::to_owned), Source::Flag),
1386 (
1387 config
1388 .map(|c| c.setup.required_check.clone())
1389 .filter(|name| !name.is_empty()),
1390 Source::Config,
1391 ),
1392 (
1393 record
1394 .map(|r| r.parameters.required_check.clone())
1395 .filter(|name| !name.is_empty()),
1396 Source::Record,
1397 ),
1398 (None, Source::Observation),
1399 (
1400 Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1401 Source::Default,
1402 ),
1403 ])
1404 .unwrap_or((String::new(), Source::Default));
1405 sources.insert("setup.required_check", source);
1406 let (required_workflow, source) = answered([
1407 (flags.required_workflow.map(str::to_owned), Source::Flag),
1408 (
1409 config
1410 .map(|c| c.setup.required_workflow.clone())
1411 .filter(|name| !name.is_empty()),
1412 Source::Config,
1413 ),
1414 (
1415 record
1416 .map(|r| r.parameters.required_workflow.clone())
1417 .filter(|name| !name.is_empty()),
1418 Source::Record,
1419 ),
1420 (None, Source::Observation),
1421 (
1422 Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1423 Source::Default,
1424 ),
1425 ])
1426 .unwrap_or((String::new(), Source::Default));
1427 sources.insert("setup.required_workflow", source);
1428 if forge.as_deref() == Some("gitlab") {
1433 for (flag, supplied) in [
1434 ("--required-check", flags.required_check),
1435 ("--required-workflow", flags.required_workflow),
1436 ] {
1437 if supplied.is_some() {
1438 return Err(RkError::Usage(format!(
1439 "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1440 )));
1441 }
1442 }
1443 }
1444
1445 let mut unknown: Vec<String> = technologies
1446 .iter()
1447 .filter(|name| !known_drivers.contains(name))
1448 .map(|name| format!("technology {name}"))
1449 .collect();
1450 if let Some(name) = &forge
1451 && crate::detect::Forge::parse(name).is_none()
1452 {
1453 unknown.push(format!("forge {name}"));
1454 }
1455
1456 Ok(Resolved {
1457 params: Params {
1458 profile: ProfileSnapshot {
1459 technologies,
1460 forge,
1461 release,
1462 },
1463 git: GitWorkflow {
1464 trunk,
1465 checkout_mode,
1466 integration,
1467 merge_form,
1468 },
1469 capabilities: CapabilityRequests {
1470 nix_packaging,
1471 reporting_policy,
1472 scorecard,
1473 code_scanning,
1474 workflow_guards: Some(workflow_guards),
1475 },
1476 repo,
1477 security_contact,
1478 security_response,
1479 required_check,
1480 required_workflow,
1481 },
1482 sources,
1483 unknown,
1484 proposal,
1485 })
1486}