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, PartialEq, Eq, Serialize, Deserialize)]
79pub struct ReleaseIntent {
80 pub mode: ReleaseMode,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub driver: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub style: Option<Style>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub line_prefix: Option<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct ProfileSnapshot {
98 pub technologies: Vec<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub forge: Option<String>,
103 pub release: ReleaseIntent,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct GitWorkflow {
110 pub trunk: String,
112 pub checkout_mode: CheckoutMode,
114 #[serde(default = "crate::landing::manifest::integration_forge")]
117 pub integration: Integration,
118 #[serde(default = "crate::landing::manifest::merge_form_squash")]
121 pub merge_form: MergeForm,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct CapabilityRequests {
127 #[serde(default)]
129 pub nix_packaging: bool,
130 #[serde(default)]
132 pub reporting_policy: bool,
133 #[serde(default)]
135 pub scorecard: bool,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub code_scanning: Option<Provider>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub workflow_guards: Option<bool>,
145}
146
147impl CapabilityRequests {
148 #[must_use]
152 pub fn workflow_guards(&self) -> bool {
153 self.workflow_guards.unwrap_or(true)
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
159#[serde(rename_all = "lowercase")]
160pub enum Source {
161 Flag,
163 Config,
165 Record,
167 Observation,
169 Default,
171}
172
173impl Source {
174 #[must_use]
180 pub const fn rank(self) -> u8 {
181 match self {
182 Self::Flag => 0,
183 Self::Config => 1,
184 Self::Record => 2,
185 Self::Observation => 3,
186 Self::Default => 4,
187 }
188 }
189
190 #[must_use]
192 pub const fn as_str(self) -> &'static str {
193 match self {
194 Self::Flag => "flag",
195 Self::Config => "config",
196 Self::Record => "record",
197 Self::Observation => "observation",
198 Self::Default => "default",
199 }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
206#[serde(rename_all = "kebab-case", tag = "state")]
207pub enum Proposal {
208 None,
210 Automatic {
212 driver: String,
214 },
215 Ambiguous {
218 drivers: Vec<String>,
220 },
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct Params {
229 profile: ProfileSnapshot,
230 git: GitWorkflow,
231 capabilities: CapabilityRequests,
232 repo: String,
233 security_contact: String,
234 security_response: String,
235 required_check: String,
236 required_workflow: String,
237 dev_environment: Option<DevEnvironment>,
238}
239
240#[derive(Default)]
242pub struct Inputs<'a> {
243 pub technologies: &'a [String],
246 pub forge: Option<&'a str>,
248 pub repo: Option<&'a str>,
250 pub release_mode: Option<ReleaseMode>,
252 pub release_driver: Option<&'a str>,
254 pub style: Option<Style>,
256 pub trunk: Option<&'a str>,
258 pub checkout_mode: Option<CheckoutMode>,
260 pub integration: Option<Integration>,
262 pub merge_form: Option<MergeForm>,
264 pub required_check: Option<&'a str>,
266 pub required_workflow: Option<&'a str>,
269 pub nix: Option<bool>,
271 pub reporting_policy: Option<bool>,
273 pub scorecard: Option<bool>,
275 pub code_scanning: Option<Option<Provider>>,
278 pub workflow_guards: Option<bool>,
280}
281
282#[derive(Clone, Copy, PartialEq, Eq)]
284pub enum Purpose {
285 Init,
287 Preview,
290 Upgrade,
292 Adopt,
294}
295
296#[derive(Debug, Clone)]
298pub struct Resolved {
299 pub params: Params,
301 pub sources: BTreeMap<&'static str, Source>,
303 pub unknown: Vec<String>,
305 pub proposal: Option<Proposal>,
308}
309
310pub fn canonical_category(raw: &str) -> Result<String, String> {
320 let lowered = raw.trim().to_ascii_lowercase();
321 let shaped = lowered
322 .chars()
323 .next()
324 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
325 && lowered
326 .chars()
327 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
328 if shaped {
329 Ok(lowered)
330 } else {
331 Err(format!(
332 "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
333 ))
334 }
335}
336
337pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
343 let mut out = Vec::with_capacity(raw.len());
344 for value in raw {
345 let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
346 if out.contains(&name) {
347 return Err(format!("{key} names {name} twice; each technology once"));
348 }
349 out.push(name);
350 }
351 out.sort();
352 Ok(out)
353}
354
355fn push_shell_word(out: &mut String, value: &str) {
358 let inert = !value.is_empty()
359 && value.bytes().all(|byte| {
360 byte.is_ascii_alphanumeric()
361 || matches!(
362 byte,
363 b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
364 )
365 });
366 if inert {
367 out.push_str(value);
368 return;
369 }
370 out.push('\'');
371 out.push_str(&value.replace('\'', "'\"'\"'"));
372 out.push('\'');
373}
374
375impl Params {
376 #[must_use]
379 pub fn from_record(record: &manifest::Manifest) -> Self {
380 Self {
381 profile: record.profile.clone(),
382 git: record.git.clone(),
383 capabilities: record.capabilities.clone(),
384 repo: record.parameters.repo.clone(),
385 security_contact: record.parameters.security_contact.clone(),
386 security_response: record.parameters.security_response.clone(),
387 required_check: gate_answer(
390 record,
391 |(check, _)| check,
392 |parameters| ¶meters.required_check,
393 ),
394 required_workflow: gate_answer(
395 record,
396 |(_, workflow)| workflow,
397 |parameters| ¶meters.required_workflow,
398 ),
399 dev_environment: record.parameters.dev_environment.clone(),
400 }
401 }
402
403 pub fn resolve(
411 target: &Utf8Path,
412 flags: &Inputs<'_>,
413 config: Option<&crate::config::Config>,
414 record: Option<&manifest::Manifest>,
415 purpose: Purpose,
416 ) -> Result<Self, RkError> {
417 resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
418 }
419
420 #[must_use]
422 pub const fn profile(&self) -> &ProfileSnapshot {
423 &self.profile
424 }
425
426 #[must_use]
428 pub const fn git(&self) -> &GitWorkflow {
429 &self.git
430 }
431
432 #[must_use]
434 pub const fn capabilities(&self) -> &CapabilityRequests {
435 &self.capabilities
436 }
437
438 #[must_use]
440 pub fn technologies(&self) -> &[String] {
441 &self.profile.technologies
442 }
443
444 #[must_use]
446 pub fn forge(&self) -> Option<&str> {
447 self.profile.forge.as_deref()
448 }
449
450 #[must_use]
452 pub const fn release_mode(&self) -> ReleaseMode {
453 self.profile.release.mode
454 }
455
456 #[must_use]
458 pub fn driver(&self) -> Option<&str> {
459 self.profile.release.driver.as_deref()
460 }
461
462 #[must_use]
464 pub const fn style(&self) -> Option<Style> {
465 self.profile.release.style
466 }
467
468 #[must_use]
470 pub const fn nix_packaging(&self) -> bool {
471 self.capabilities.nix_packaging
472 }
473
474 #[must_use]
476 pub const fn reporting_policy(&self) -> bool {
477 self.capabilities.reporting_policy
478 }
479
480 #[must_use]
482 pub const fn scorecard(&self) -> bool {
483 self.capabilities.scorecard
484 }
485
486 #[must_use]
488 pub const fn code_scanning(&self) -> Option<Provider> {
489 self.capabilities.code_scanning
490 }
491
492 #[must_use]
494 pub fn workflow_guards(&self) -> bool {
495 self.capabilities.workflow_guards()
496 }
497
498 #[must_use]
501 pub fn repo(&self) -> &str {
502 &self.repo
503 }
504
505 #[must_use]
507 pub const fn checkout_mode(&self) -> CheckoutMode {
508 self.git.checkout_mode
509 }
510
511 #[must_use]
513 pub const fn integration(&self) -> Integration {
514 self.git.integration
515 }
516
517 #[must_use]
519 pub const fn merge_form(&self) -> MergeForm {
520 self.git.merge_form
521 }
522
523 #[must_use]
525 pub fn trunk(&self) -> &str {
526 &self.git.trunk
527 }
528
529 #[must_use]
532 pub fn line_prefix(&self) -> &str {
533 self.profile
534 .release
535 .line_prefix
536 .as_deref()
537 .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
538 }
539
540 #[must_use]
543 pub fn security_contact(&self) -> &str {
544 &self.security_contact
545 }
546
547 #[must_use]
549 pub fn security_response(&self) -> &str {
550 &self.security_response
551 }
552
553 #[must_use]
556 pub fn required_check(&self) -> &str {
557 &self.required_check
558 }
559
560 #[must_use]
563 pub const fn dev_environment(&self) -> Option<&DevEnvironment> {
564 self.dev_environment.as_ref()
565 }
566
567 #[must_use]
570 pub fn required_workflow(&self) -> &str {
571 &self.required_workflow
572 }
573
574 #[must_use]
578 pub fn canonical_flags(&self) -> String {
579 let mut out = String::new();
580 for technology in &self.profile.technologies {
581 out.push_str(" --technology ");
582 out.push_str(technology);
583 }
584 if let Some(forge) = &self.profile.forge {
585 out.push_str(" --forge ");
586 out.push_str(forge);
587 }
588 if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
592 out.push_str(" --repo ");
593 out.push_str(&self.repo);
594 }
595 out.push_str(" --release-mode ");
596 out.push_str(self.profile.release.mode.as_str());
597 if let Some(driver) = &self.profile.release.driver {
598 out.push_str(" --release-driver ");
599 out.push_str(driver);
600 }
601 if let Some(style) = self.profile.release.style {
602 out.push_str(" --release-style ");
603 out.push_str(style.as_str());
604 }
605 out.push_str(" --trunk ");
606 out.push_str(&self.git.trunk);
607 out.push_str(" --checkout-mode ");
608 out.push_str(self.git.checkout_mode.as_str());
609 out.push_str(" --integration ");
610 out.push_str(self.git.integration.as_str());
611 out.push_str(" --merge-form ");
612 out.push_str(self.git.merge_form.as_str());
613 if !self.required_check.is_empty() {
617 out.push_str(" --required-check ");
618 push_shell_word(&mut out, &self.required_check);
619 }
620 if !self.required_workflow.is_empty() {
621 out.push_str(" --required-workflow ");
622 push_shell_word(&mut out, &self.required_workflow);
623 }
624 out
625 }
626
627 #[must_use]
633 pub fn capability_flags(&self) -> String {
634 let mut out = String::new();
635 if self.capabilities.nix_packaging {
636 out.push_str(" --nix-packaging");
637 }
638 if self.capabilities.reporting_policy {
639 out.push_str(" --reporting-policy");
640 }
641 if self.capabilities.scorecard {
642 out.push_str(" --scorecard");
643 }
644 if self.workflow_guards() {
645 out.push_str(" --workflow-guards");
646 }
647 out.push_str(" --code-scanning ");
654 out.push_str(
655 self.capabilities
656 .code_scanning
657 .map_or("off", Provider::as_str),
658 );
659 out
660 }
661
662 #[must_use]
669 pub fn capability_toggles(&self) -> String {
670 let word = |on: bool| if on { "on" } else { "off" };
671 format!(
672 " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {} --workflow-guards {}",
673 word(self.capabilities.nix_packaging),
674 word(self.capabilities.reporting_policy),
675 word(self.capabilities.scorecard),
676 self.capabilities
677 .code_scanning
678 .map_or("off", Provider::as_str),
679 word(self.workflow_guards())
680 )
681 }
682}
683
684#[cfg(test)]
685impl Params {
686 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
691 Self {
692 profile: ProfileSnapshot {
693 technologies: vec!["rust".to_owned()],
694 forge: Some("github".to_owned()),
695 release: ReleaseIntent {
696 mode: ReleaseMode::Automatic,
697 driver: Some("rust".to_owned()),
698 style,
699 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
700 },
701 },
702 git: GitWorkflow {
703 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
704 checkout_mode: CheckoutMode::LinkedWorktree,
705 integration: Integration::Local,
706 merge_form: MergeForm::Squash,
707 },
708 capabilities: CapabilityRequests {
709 nix_packaging: false,
710 reporting_policy: true,
711 scorecard: false,
712 code_scanning: None,
713 workflow_guards: Some(true),
714 },
715 repo: repo.to_owned(),
716 security_contact: String::new(),
717 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
718 required_check: "gate".to_owned(),
719 required_workflow: "ci".to_owned(),
720 dev_environment: None,
721 }
722 }
723
724 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
726 Self {
727 security_contact: contact.to_owned(),
728 security_response: response.to_owned(),
729 ..Self::for_test("acme/widget", Some(Style::Trunk))
730 }
731 }
732
733 pub(crate) fn for_test_release_less(
736 technologies: &[&str],
737 forge: Option<&str>,
738 mode: ReleaseMode,
739 ) -> Self {
740 let mut params = Self::for_test("acme/widget", None);
741 params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
742 params.profile.forge = forge.map(str::to_owned);
743 params.profile.release = ReleaseIntent {
744 mode,
745 driver: None,
746 style: None,
747 line_prefix: None,
748 };
749 params.capabilities.reporting_policy = false;
750 if forge.is_none() {
751 params.repo = String::new();
752 }
753 params
754 }
755
756 pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
758 self.profile.technologies = vec![driver.to_owned()];
759 self.profile.release.driver = Some(driver.to_owned());
760 self.profile.forge = Some(forge.to_owned());
761 }
762
763 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
765 self.git.checkout_mode = mode;
766 }
767
768 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
770 self.git.integration = mode;
771 }
772
773 pub(crate) const fn set_merge_form_for_test(&mut self, form: MergeForm) {
775 self.git.merge_form = form;
776 }
777
778 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
780 self.capabilities.nix_packaging = nix;
781 }
782
783 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
785 self.capabilities.scorecard = scorecard;
786 }
787
788 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
790 self.capabilities.code_scanning = provider;
791 }
792
793 pub(crate) const fn set_workflow_guards_for_test(&mut self, on: bool) {
795 self.capabilities.workflow_guards = Some(on);
796 }
797}
798
799fn invalid_release(message: impl std::fmt::Display) -> RkError {
802 RkError::Usage(format!(
803 "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
804 ))
805}
806
807#[must_use]
829pub fn gate_defaults(
830 profile: &ProfileSnapshot,
831 git: &GitWorkflow,
832) -> Option<(&'static str, &'static str)> {
833 let consuming = profile.forge.as_deref() == Some("github")
834 && profile.release.mode == ReleaseMode::Automatic
835 && profile.release.style == Some(Style::Trunk)
836 && git.integration == Integration::Local;
837 consuming.then_some(("gate", "ci"))
838}
839
840fn gate_answer(
843 record: &manifest::Manifest,
844 pick: impl Fn((&'static str, &'static str)) -> &'static str,
845 held: impl Fn(&manifest::Parameters) -> &String,
846) -> String {
847 let recorded = held(&record.parameters);
848 if recorded.is_empty() {
849 gate_defaults(&record.profile, &record.git)
850 .map_or_else(String::new, |pair| pick(pair).to_owned())
851 } else {
852 recorded.clone()
853 }
854}
855
856fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
858 chain
859 .into_iter()
860 .find_map(|(value, source)| value.map(|value| (value, source)))
861}
862
863#[allow(
871 clippy::too_many_lines,
872 reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
873)]
874pub fn resolve(
875 target: &Utf8Path,
876 flags: &Inputs<'_>,
877 config: Option<&crate::config::Config>,
878 record: Option<&manifest::Manifest>,
879 purpose: Purpose,
880) -> Result<Resolved, RkError> {
881 let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
882 let observed = crate::detect::observe(target.as_std_path());
883 let known_drivers = catalog::known_drivers();
884
885 let (technologies, source) = answered([
887 (
888 (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
889 Source::Flag,
890 ),
891 (
892 config.and_then(|c| c.profile.technologies.clone()),
893 Source::Config,
894 ),
895 (
896 record.map(|r| r.profile.technologies.clone()),
897 Source::Record,
898 ),
899 (
900 Some(
901 observed
902 .technologies
903 .iter()
904 .map(|t| (*t).to_owned())
905 .collect(),
906 ),
907 Source::Observation,
908 ),
909 (None, Source::Default),
910 ])
911 .unwrap_or_else(|| (Vec::new(), Source::Default));
912 let technologies =
913 canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
914 sources.insert("profile.technologies", source);
915
916 let forge_flag = flags
918 .forge
919 .map(|name| canonical_category(name).map_err(RkError::Usage))
920 .transpose()?;
921 let (forge, source) = answered([
922 (forge_flag.map(Some), Source::Flag),
923 (
924 config
925 .and_then(|c| c.profile.forge.clone())
926 .map(|value| if value.is_empty() { None } else { Some(value) }),
927 Source::Config,
928 ),
929 (record.map(|r| r.profile.forge.clone()), Source::Record),
930 (
931 observed.forge.map(|forge| Some(forge.as_str().to_owned())),
932 Source::Observation,
933 ),
934 (Some(None), Source::Default),
935 ])
936 .unwrap_or((None, Source::Default));
937 let forge = forge
938 .map(|name| canonical_category(&name).map_err(RkError::Usage))
939 .transpose()?;
940 sources.insert("profile.forge", source);
941
942 let (repo, source) = answered([
944 (flags.repo.map(str::to_owned), Source::Flag),
945 (
946 config
947 .map(|c| c.project.repo.clone())
948 .filter(|value| !value.is_empty()),
949 Source::Config,
950 ),
951 (
952 record
953 .map(|r| r.parameters.repo.clone())
954 .filter(|value| !value.is_empty()),
955 Source::Record,
956 ),
957 (observed.repo.clone(), Source::Observation),
958 (None, Source::Default),
959 ])
960 .map_or((None, Source::Default), |(value, source)| {
961 (Some(value), source)
962 });
963 sources.insert("project.repo", source);
964
965 let release_bearing: Vec<String> = technologies
968 .iter()
969 .filter(|name| known_drivers.contains(name))
970 .cloned()
971 .collect();
972 let proposal = match release_bearing.as_slice() {
973 [] => Proposal::None,
974 [one] => Proposal::Automatic {
975 driver: one.clone(),
976 },
977 many => Proposal::Ambiguous {
978 drivers: many.to_vec(),
979 },
980 };
981 let proposed_mode = match &proposal {
988 Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
989 Proposal::None => ReleaseMode::None,
990 };
991 let (mode, source) = answered([
992 (flags.release_mode, Source::Flag),
993 (config.and_then(|c| c.profile.release.mode), Source::Config),
994 (record.map(|r| r.profile.release.mode), Source::Record),
995 (Some(proposed_mode), Source::Observation),
996 (None, Source::Default),
997 ])
998 .unwrap_or((ReleaseMode::None, Source::Default));
999 let mode_source = source;
1000 sources.insert("profile.release.mode", mode_source);
1001 let mode_answered_above = mode_source != Source::Observation;
1002 let proposal = (!mode_answered_above).then_some(proposal);
1003
1004 let driver_flag = flags
1008 .release_driver
1009 .map(|name| canonical_category(name).map_err(RkError::Usage))
1010 .transpose()?;
1011 let (driver, driver_source) = answered([
1012 (driver_flag, Source::Flag),
1013 (
1014 config.and_then(|c| c.profile.release.driver.clone()),
1015 Source::Config,
1016 ),
1017 (
1018 record.and_then(|r| r.profile.release.driver.clone()),
1019 Source::Record,
1020 ),
1021 (
1022 match &proposal {
1023 Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
1024 Some(driver.clone())
1025 }
1026 _ => None,
1027 },
1028 Source::Observation,
1029 ),
1030 (None, Source::Default),
1031 ])
1032 .map_or((None, Source::Default), |(value, source)| {
1033 (Some(value), source)
1034 });
1035 let (style, style_source) = answered([
1036 (flags.style, Source::Flag),
1037 (config.and_then(|c| c.profile.release.style), Source::Config),
1038 (record.and_then(|r| r.profile.release.style), Source::Record),
1039 (None, Source::Observation),
1040 (
1041 (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
1042 .then_some(Style::Trunk),
1043 Source::Default,
1044 ),
1045 ])
1046 .map_or((None, Source::Default), |(value, source)| {
1047 (Some(value), source)
1048 });
1049 let (line_prefix, prefix_source) = answered([
1050 (None, Source::Flag),
1051 (
1052 config.and_then(|c| c.profile.release.line_prefix.clone()),
1053 Source::Config,
1054 ),
1055 (
1056 record.and_then(|r| r.profile.release.line_prefix.clone()),
1057 Source::Record,
1058 ),
1059 (None, Source::Observation),
1060 (
1061 (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1062 Source::Default,
1063 ),
1064 ])
1065 .map_or((None, Source::Default), |(value, source)| {
1066 (Some(value), source)
1067 });
1068
1069 let reporting = purpose == Purpose::Preview;
1074 let release = match mode {
1075 ReleaseMode::Automatic => {
1076 if let Some(Proposal::Ambiguous { drivers }) = &proposal
1077 && driver.is_none()
1078 && !reporting
1079 {
1080 return Err(RkError::Usage(format!(
1081 "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1082 drivers.join(" and "),
1083 crate::config::CONFIG_PATH
1084 )));
1085 }
1086 if forge.is_none() && !reporting {
1087 let message = observed.host.map_or_else(
1088 || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1089 |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1090 );
1091 return Err(RkError::refusal(
1092 Diagnostic::new(Reason::ForgeUndetected, message)
1093 .expected("a github.com or gitlab remote, or --forge")
1094 .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1095 ));
1096 }
1097 if driver.is_none() && !reporting {
1098 return Err(invalid_release(format!(
1099 "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1100 known_drivers.join("|")
1101 )));
1102 }
1103 if let Some(driver) = &driver
1104 && !technologies.contains(driver)
1105 && !reporting
1106 {
1107 return Err(invalid_release(format!(
1108 "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1109 if technologies.is_empty() {
1110 "empty".to_owned()
1111 } else {
1112 technologies.join(", ")
1113 }
1114 )));
1115 }
1116 let style = match (style, purpose) {
1117 (None, Purpose::Upgrade | Purpose::Adopt) => {
1118 return Err(RkError::Usage(
1119 "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1120 ));
1121 }
1122 (None, _) => Style::Trunk,
1123 (Some(style), _) => style,
1124 };
1125 sources.insert("profile.release.driver", driver_source);
1126 sources.insert("profile.release.style", style_source);
1127 sources.insert("profile.release.line_prefix", prefix_source);
1128 ReleaseIntent {
1129 mode,
1130 driver,
1131 style: Some(style),
1132 line_prefix: Some(
1133 line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1134 ),
1135 }
1136 }
1137 ReleaseMode::External | ReleaseMode::None => {
1138 for (key, present, value_source) in [
1145 ("profile.release.driver", driver.is_some(), driver_source),
1146 ("profile.release.style", style.is_some(), style_source),
1147 (
1148 "profile.release.line_prefix",
1149 line_prefix.is_some(),
1150 prefix_source,
1151 ),
1152 ] {
1153 if present
1154 && matches!(value_source, Source::Flag | Source::Config)
1155 && value_source.rank() <= mode_source.rank()
1156 {
1157 return Err(invalid_release(format!(
1158 "{key} is set while profile.release.mode is {}",
1159 mode.as_str()
1160 )));
1161 }
1162 }
1163 ReleaseIntent {
1164 mode,
1165 driver: None,
1166 style: None,
1167 line_prefix: None,
1168 }
1169 }
1170 };
1171
1172 let adapter_known = forge
1176 .as_deref()
1177 .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1178 let repo = match (forge.is_some(), adapter_known, repo) {
1179 (false, _, _) => String::new(),
1182 (true, false, repo) => repo.unwrap_or_default(),
1183 (true, true, Some(repo)) => repo,
1184 (true, true, None) if purpose == Purpose::Preview => {
1185 crate::projection::REPO_PLACEHOLDER.to_owned()
1186 }
1187 (true, true, None) => return Err(crate::landing::repo_unresolved()),
1188 };
1189
1190 let (trunk, source) = answered([
1192 (flags.trunk.map(str::to_owned), Source::Flag),
1193 (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1194 (record.map(|r| r.git.trunk.clone()), Source::Record),
1195 (None, Source::Observation),
1196 (
1197 Some(crate::config::TRUNK_DEFAULT.to_owned()),
1198 Source::Default,
1199 ),
1200 ])
1201 .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1202 sources.insert("git.trunk", source);
1203 let (checkout_mode, source) = answered([
1204 (flags.checkout_mode, Source::Flag),
1205 (config.and_then(|c| c.git.checkout_mode), Source::Config),
1206 (record.map(|r| r.git.checkout_mode), Source::Record),
1207 (None, Source::Observation),
1208 (
1209 Some(if purpose == Purpose::Adopt {
1210 CheckoutMode::MainWorktree
1211 } else {
1212 CheckoutMode::LinkedWorktree
1213 }),
1214 Source::Default,
1215 ),
1216 ])
1217 .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1218 sources.insert("git.checkout_mode", source);
1219 let (integration, source) = answered([
1224 (flags.integration, Source::Flag),
1225 (config.and_then(|c| c.git.integration), Source::Config),
1226 (record.map(|r| r.git.integration), Source::Record),
1227 (None, Source::Observation),
1228 (
1233 Some(if purpose == Purpose::Adopt {
1234 Integration::Forge
1235 } else {
1236 Integration::Local
1237 }),
1238 Source::Default,
1239 ),
1240 ])
1241 .unwrap_or((Integration::Local, Source::Default));
1242 sources.insert("git.integration", source);
1243 let (merge_form, source) = answered([
1247 (flags.merge_form, Source::Flag),
1248 (config.and_then(|c| c.git.merge_form), Source::Config),
1249 (record.map(|r| r.git.merge_form), Source::Record),
1250 (None, Source::Observation),
1251 (Some(manifest::merge_form_squash()), Source::Default),
1252 ])
1253 .unwrap_or((MergeForm::Squash, Source::Default));
1254 sources.insert("git.merge_form", source);
1255
1256 let (nix_packaging, source) = answered([
1258 (flags.nix, Source::Flag),
1259 (
1260 config.and_then(|c| c.capabilities.nix_packaging),
1261 Source::Config,
1262 ),
1263 (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1264 (None, Source::Observation),
1265 (Some(false), Source::Default),
1266 ])
1267 .unwrap_or((false, Source::Default));
1268 sources.insert("capabilities.nix_packaging", source);
1269 let (reporting_policy, source) = answered([
1270 (flags.reporting_policy, Source::Flag),
1271 (
1272 config.and_then(|c| c.capabilities.reporting_policy),
1273 Source::Config,
1274 ),
1275 (
1276 record.map(|r| r.capabilities.reporting_policy),
1277 Source::Record,
1278 ),
1279 (None, Source::Observation),
1280 (
1283 Some(release.mode == ReleaseMode::Automatic),
1284 Source::Default,
1285 ),
1286 ])
1287 .unwrap_or((false, Source::Default));
1288 sources.insert("capabilities.reporting_policy", source);
1289 let (scorecard, source) = answered([
1290 (flags.scorecard, Source::Flag),
1291 (
1292 config.and_then(|c| c.capabilities.scorecard),
1293 Source::Config,
1294 ),
1295 (record.map(|r| r.capabilities.scorecard), Source::Record),
1296 (None, Source::Observation),
1297 (Some(false), Source::Default),
1298 ])
1299 .unwrap_or((false, Source::Default));
1300 sources.insert("capabilities.scorecard", source);
1301 let configured_scanning = config
1302 .and_then(|c| c.capabilities.code_scanning.as_deref())
1303 .map(Provider::parse)
1304 .transpose()?;
1305 let (code_scanning, source) = answered([
1306 (flags.code_scanning, Source::Flag),
1307 (configured_scanning, Source::Config),
1308 (record.map(|r| r.capabilities.code_scanning), Source::Record),
1309 (None, Source::Observation),
1310 (Some(None), Source::Default),
1311 ])
1312 .unwrap_or((None, Source::Default));
1313 sources.insert("capabilities.code_scanning", source);
1314 let (workflow_guards, source) = answered([
1319 (flags.workflow_guards, Source::Flag),
1320 (
1321 config.and_then(|c| c.capabilities.workflow_guards),
1322 Source::Config,
1323 ),
1324 (
1325 record.map(|r| r.capabilities.workflow_guards()),
1326 Source::Record,
1327 ),
1328 (None, Source::Observation),
1329 (Some(false), Source::Default),
1330 ])
1331 .unwrap_or((false, Source::Default));
1332 sources.insert("capabilities.workflow_guards", source);
1333 let (security_contact, source) = answered([
1341 (None, Source::Flag),
1342 (
1343 config.and_then(|c| c.security.contact.clone()),
1344 Source::Config,
1345 ),
1346 (
1347 record.map(|r| r.parameters.security_contact.clone()),
1348 Source::Record,
1349 ),
1350 (None, Source::Observation),
1351 (Some(String::new()), Source::Default),
1352 ])
1353 .unwrap_or((String::new(), Source::Default));
1354 let security_contact =
1355 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1356 sources.insert("security.contact", source);
1357 let (security_response, source) = answered([
1358 (None, Source::Flag),
1359 (
1360 config.and_then(|c| c.security.response.clone()),
1361 Source::Config,
1362 ),
1363 (
1364 record.map(|r| r.parameters.security_response.clone()),
1365 Source::Record,
1366 ),
1367 (None, Source::Observation),
1368 (
1369 Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1370 Source::Default,
1371 ),
1372 ])
1373 .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1374 let security_response =
1375 crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1376 sources.insert("security.response", source);
1377
1378 let gate_default = gate_defaults(
1383 &ProfileSnapshot {
1384 technologies: technologies.clone(),
1385 forge: forge.clone(),
1386 release: release.clone(),
1387 },
1388 &GitWorkflow {
1389 trunk: trunk.clone(),
1390 checkout_mode,
1391 integration,
1392 merge_form,
1393 },
1394 );
1395 let (required_check, source) = answered([
1396 (flags.required_check.map(str::to_owned), Source::Flag),
1397 (
1398 config
1399 .map(|c| c.setup.required_check.clone())
1400 .filter(|name| !name.is_empty()),
1401 Source::Config,
1402 ),
1403 (
1404 record
1405 .map(|r| r.parameters.required_check.clone())
1406 .filter(|name| !name.is_empty()),
1407 Source::Record,
1408 ),
1409 (None, Source::Observation),
1410 (
1411 Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1412 Source::Default,
1413 ),
1414 ])
1415 .unwrap_or((String::new(), Source::Default));
1416 sources.insert("setup.required_check", source);
1417 let (required_workflow, source) = answered([
1418 (flags.required_workflow.map(str::to_owned), Source::Flag),
1419 (
1420 config
1421 .map(|c| c.setup.required_workflow.clone())
1422 .filter(|name| !name.is_empty()),
1423 Source::Config,
1424 ),
1425 (
1426 record
1427 .map(|r| r.parameters.required_workflow.clone())
1428 .filter(|name| !name.is_empty()),
1429 Source::Record,
1430 ),
1431 (None, Source::Observation),
1432 (
1433 Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1434 Source::Default,
1435 ),
1436 ])
1437 .unwrap_or((String::new(), Source::Default));
1438 sources.insert("setup.required_workflow", source);
1439 let recorded = || {
1443 record
1444 .and_then(|r| r.parameters.dev_environment.clone())
1445 .map_or((None, Source::Default), |answer| {
1446 (Some(answer), Source::Record)
1447 })
1448 };
1449 let (dev_environment, source) = config
1450 .and_then(|c| c.dev_environment.clone())
1451 .map_or_else(recorded, |answer| (answer.answered(), Source::Config));
1452 if let Some(answer) = &dev_environment {
1453 answer
1454 .check_forge(forge.as_deref())
1455 .map_err(crate::config::invalid)?;
1456 }
1457 sources.insert("dev_environment", source);
1458 if forge.as_deref() == Some("gitlab") {
1463 for (flag, supplied) in [
1464 ("--required-check", flags.required_check),
1465 ("--required-workflow", flags.required_workflow),
1466 ] {
1467 if supplied.is_some() {
1468 return Err(RkError::Usage(format!(
1469 "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1470 )));
1471 }
1472 }
1473 }
1474
1475 let mut unknown: Vec<String> = technologies
1476 .iter()
1477 .filter(|name| !known_drivers.contains(name))
1478 .map(|name| format!("technology {name}"))
1479 .collect();
1480 if let Some(name) = &forge
1481 && crate::detect::Forge::parse(name).is_none()
1482 {
1483 unknown.push(format!("forge {name}"));
1484 }
1485
1486 Ok(Resolved {
1487 params: Params {
1488 profile: ProfileSnapshot {
1489 technologies,
1490 forge,
1491 release,
1492 },
1493 git: GitWorkflow {
1494 trunk,
1495 checkout_mode,
1496 integration,
1497 merge_form,
1498 },
1499 capabilities: CapabilityRequests {
1500 nix_packaging,
1501 reporting_policy,
1502 scorecard,
1503 code_scanning,
1504 workflow_guards: Some(workflow_guards),
1505 },
1506 repo,
1507 security_contact,
1508 security_response,
1509 required_check,
1510 required_workflow,
1511 dev_environment,
1512 },
1513 sources,
1514 unknown,
1515 proposal,
1516 })
1517}