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, 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}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct CapabilityRequests {
122 #[serde(default)]
124 pub nix_packaging: bool,
125 #[serde(default)]
127 pub reporting_policy: bool,
128 #[serde(default)]
130 pub scorecard: bool,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub code_scanning: Option<Provider>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub workflow_guards: Option<bool>,
140}
141
142impl CapabilityRequests {
143 #[must_use]
147 pub fn workflow_guards(&self) -> bool {
148 self.workflow_guards.unwrap_or(true)
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "lowercase")]
155pub enum Source {
156 Flag,
158 Config,
160 Record,
162 Observation,
164 Default,
166}
167
168impl Source {
169 #[must_use]
175 pub const fn rank(self) -> u8 {
176 match self {
177 Self::Flag => 0,
178 Self::Config => 1,
179 Self::Record => 2,
180 Self::Observation => 3,
181 Self::Default => 4,
182 }
183 }
184
185 #[must_use]
187 pub const fn as_str(self) -> &'static str {
188 match self {
189 Self::Flag => "flag",
190 Self::Config => "config",
191 Self::Record => "record",
192 Self::Observation => "observation",
193 Self::Default => "default",
194 }
195 }
196}
197
198#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
201#[serde(rename_all = "kebab-case", tag = "state")]
202pub enum Proposal {
203 None,
205 Automatic {
207 driver: String,
209 },
210 Ambiguous {
213 drivers: Vec<String>,
215 },
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct Params {
224 profile: ProfileSnapshot,
225 git: GitWorkflow,
226 capabilities: CapabilityRequests,
227 repo: String,
228 security_contact: String,
229 security_response: String,
230 required_check: String,
231 required_workflow: String,
232}
233
234#[derive(Default)]
236pub struct Inputs<'a> {
237 pub technologies: &'a [String],
240 pub forge: Option<&'a str>,
242 pub repo: Option<&'a str>,
244 pub release_mode: Option<ReleaseMode>,
246 pub release_driver: Option<&'a str>,
248 pub style: Option<Style>,
250 pub trunk: Option<&'a str>,
252 pub checkout_mode: Option<CheckoutMode>,
254 pub integration: Option<Integration>,
256 pub required_check: Option<&'a str>,
258 pub required_workflow: Option<&'a str>,
261 pub nix: Option<bool>,
263 pub reporting_policy: Option<bool>,
265 pub scorecard: Option<bool>,
267 pub code_scanning: Option<Option<Provider>>,
270 pub workflow_guards: Option<bool>,
272}
273
274#[derive(Clone, Copy, PartialEq, Eq)]
276pub enum Purpose {
277 Init,
279 Preview,
282 Upgrade,
284 Adopt,
286}
287
288#[derive(Debug, Clone)]
290pub struct Resolved {
291 pub params: Params,
293 pub sources: BTreeMap<&'static str, Source>,
295 pub unknown: Vec<String>,
297 pub proposal: Option<Proposal>,
300}
301
302pub fn canonical_category(raw: &str) -> Result<String, String> {
312 let lowered = raw.trim().to_ascii_lowercase();
313 let shaped = lowered
314 .chars()
315 .next()
316 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
317 && lowered
318 .chars()
319 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
320 if shaped {
321 Ok(lowered)
322 } else {
323 Err(format!(
324 "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
325 ))
326 }
327}
328
329pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
335 let mut out = Vec::with_capacity(raw.len());
336 for value in raw {
337 let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
338 if out.contains(&name) {
339 return Err(format!("{key} names {name} twice; each technology once"));
340 }
341 out.push(name);
342 }
343 out.sort();
344 Ok(out)
345}
346
347fn push_shell_word(out: &mut String, value: &str) {
350 let inert = !value.is_empty()
351 && value.bytes().all(|byte| {
352 byte.is_ascii_alphanumeric()
353 || matches!(
354 byte,
355 b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
356 )
357 });
358 if inert {
359 out.push_str(value);
360 return;
361 }
362 out.push('\'');
363 out.push_str(&value.replace('\'', "'\"'\"'"));
364 out.push('\'');
365}
366
367impl Params {
368 #[must_use]
371 pub fn from_record(record: &manifest::Manifest) -> Self {
372 Self {
373 profile: record.profile.clone(),
374 git: record.git.clone(),
375 capabilities: record.capabilities.clone(),
376 repo: record.parameters.repo.clone(),
377 security_contact: record.parameters.security_contact.clone(),
378 security_response: record.parameters.security_response.clone(),
379 required_check: gate_answer(
382 record,
383 |(check, _)| check,
384 |parameters| ¶meters.required_check,
385 ),
386 required_workflow: gate_answer(
387 record,
388 |(_, workflow)| workflow,
389 |parameters| ¶meters.required_workflow,
390 ),
391 }
392 }
393
394 pub fn resolve(
402 target: &Utf8Path,
403 flags: &Inputs<'_>,
404 config: Option<&crate::config::Config>,
405 record: Option<&manifest::Manifest>,
406 purpose: Purpose,
407 ) -> Result<Self, RkError> {
408 resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
409 }
410
411 #[must_use]
413 pub const fn profile(&self) -> &ProfileSnapshot {
414 &self.profile
415 }
416
417 #[must_use]
419 pub const fn git(&self) -> &GitWorkflow {
420 &self.git
421 }
422
423 #[must_use]
425 pub const fn capabilities(&self) -> &CapabilityRequests {
426 &self.capabilities
427 }
428
429 #[must_use]
431 pub fn technologies(&self) -> &[String] {
432 &self.profile.technologies
433 }
434
435 #[must_use]
437 pub fn forge(&self) -> Option<&str> {
438 self.profile.forge.as_deref()
439 }
440
441 #[must_use]
443 pub const fn release_mode(&self) -> ReleaseMode {
444 self.profile.release.mode
445 }
446
447 #[must_use]
449 pub fn driver(&self) -> Option<&str> {
450 self.profile.release.driver.as_deref()
451 }
452
453 #[must_use]
455 pub const fn style(&self) -> Option<Style> {
456 self.profile.release.style
457 }
458
459 #[must_use]
461 pub const fn nix_packaging(&self) -> bool {
462 self.capabilities.nix_packaging
463 }
464
465 #[must_use]
467 pub const fn reporting_policy(&self) -> bool {
468 self.capabilities.reporting_policy
469 }
470
471 #[must_use]
473 pub const fn scorecard(&self) -> bool {
474 self.capabilities.scorecard
475 }
476
477 #[must_use]
479 pub const fn code_scanning(&self) -> Option<Provider> {
480 self.capabilities.code_scanning
481 }
482
483 #[must_use]
485 pub fn workflow_guards(&self) -> bool {
486 self.capabilities.workflow_guards()
487 }
488
489 #[must_use]
492 pub fn repo(&self) -> &str {
493 &self.repo
494 }
495
496 #[must_use]
498 pub const fn checkout_mode(&self) -> CheckoutMode {
499 self.git.checkout_mode
500 }
501
502 #[must_use]
504 pub const fn integration(&self) -> Integration {
505 self.git.integration
506 }
507
508 #[must_use]
510 pub fn trunk(&self) -> &str {
511 &self.git.trunk
512 }
513
514 #[must_use]
517 pub fn line_prefix(&self) -> &str {
518 self.profile
519 .release
520 .line_prefix
521 .as_deref()
522 .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
523 }
524
525 #[must_use]
528 pub fn security_contact(&self) -> &str {
529 &self.security_contact
530 }
531
532 #[must_use]
534 pub fn security_response(&self) -> &str {
535 &self.security_response
536 }
537
538 #[must_use]
541 pub fn required_check(&self) -> &str {
542 &self.required_check
543 }
544
545 #[must_use]
548 pub fn required_workflow(&self) -> &str {
549 &self.required_workflow
550 }
551
552 #[must_use]
556 pub fn canonical_flags(&self) -> String {
557 let mut out = String::new();
558 for technology in &self.profile.technologies {
559 out.push_str(" --technology ");
560 out.push_str(technology);
561 }
562 if let Some(forge) = &self.profile.forge {
563 out.push_str(" --forge ");
564 out.push_str(forge);
565 }
566 if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
570 out.push_str(" --repo ");
571 out.push_str(&self.repo);
572 }
573 out.push_str(" --release-mode ");
574 out.push_str(self.profile.release.mode.as_str());
575 if let Some(driver) = &self.profile.release.driver {
576 out.push_str(" --release-driver ");
577 out.push_str(driver);
578 }
579 if let Some(style) = self.profile.release.style {
580 out.push_str(" --release-style ");
581 out.push_str(style.as_str());
582 }
583 out.push_str(" --trunk ");
584 out.push_str(&self.git.trunk);
585 out.push_str(" --checkout-mode ");
586 out.push_str(self.git.checkout_mode.as_str());
587 out.push_str(" --integration ");
588 out.push_str(self.git.integration.as_str());
589 if !self.required_check.is_empty() {
593 out.push_str(" --required-check ");
594 push_shell_word(&mut out, &self.required_check);
595 }
596 if !self.required_workflow.is_empty() {
597 out.push_str(" --required-workflow ");
598 push_shell_word(&mut out, &self.required_workflow);
599 }
600 out
601 }
602
603 #[must_use]
609 pub fn capability_flags(&self) -> String {
610 let mut out = String::new();
611 if self.capabilities.nix_packaging {
612 out.push_str(" --nix-packaging");
613 }
614 if self.capabilities.reporting_policy {
615 out.push_str(" --reporting-policy");
616 }
617 if self.capabilities.scorecard {
618 out.push_str(" --scorecard");
619 }
620 if self.workflow_guards() {
621 out.push_str(" --workflow-guards");
622 }
623 out.push_str(" --code-scanning ");
630 out.push_str(
631 self.capabilities
632 .code_scanning
633 .map_or("off", Provider::as_str),
634 );
635 out
636 }
637
638 #[must_use]
645 pub fn capability_toggles(&self) -> String {
646 let word = |on: bool| if on { "on" } else { "off" };
647 format!(
648 " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {} --workflow-guards {}",
649 word(self.capabilities.nix_packaging),
650 word(self.capabilities.reporting_policy),
651 word(self.capabilities.scorecard),
652 self.capabilities
653 .code_scanning
654 .map_or("off", Provider::as_str),
655 word(self.workflow_guards())
656 )
657 }
658}
659
660#[cfg(test)]
661impl Params {
662 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
667 Self {
668 profile: ProfileSnapshot {
669 technologies: vec!["rust".to_owned()],
670 forge: Some("github".to_owned()),
671 release: ReleaseIntent {
672 mode: ReleaseMode::Automatic,
673 driver: Some("rust".to_owned()),
674 style,
675 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
676 },
677 },
678 git: GitWorkflow {
679 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
680 checkout_mode: CheckoutMode::LinkedWorktree,
681 integration: Integration::Local,
682 },
683 capabilities: CapabilityRequests {
684 nix_packaging: false,
685 reporting_policy: true,
686 scorecard: false,
687 code_scanning: None,
688 workflow_guards: Some(true),
689 },
690 repo: repo.to_owned(),
691 security_contact: String::new(),
692 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
693 required_check: "gate".to_owned(),
694 required_workflow: "ci".to_owned(),
695 }
696 }
697
698 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
700 Self {
701 security_contact: contact.to_owned(),
702 security_response: response.to_owned(),
703 ..Self::for_test("acme/widget", Some(Style::Trunk))
704 }
705 }
706
707 pub(crate) fn for_test_release_less(
710 technologies: &[&str],
711 forge: Option<&str>,
712 mode: ReleaseMode,
713 ) -> Self {
714 let mut params = Self::for_test("acme/widget", None);
715 params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
716 params.profile.forge = forge.map(str::to_owned);
717 params.profile.release = ReleaseIntent {
718 mode,
719 driver: None,
720 style: None,
721 line_prefix: None,
722 };
723 params.capabilities.reporting_policy = false;
724 if forge.is_none() {
725 params.repo = String::new();
726 }
727 params
728 }
729
730 pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
732 self.profile.technologies = vec![driver.to_owned()];
733 self.profile.release.driver = Some(driver.to_owned());
734 self.profile.forge = Some(forge.to_owned());
735 }
736
737 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
739 self.git.checkout_mode = mode;
740 }
741
742 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
744 self.git.integration = mode;
745 }
746
747 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
749 self.capabilities.nix_packaging = nix;
750 }
751
752 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
754 self.capabilities.scorecard = scorecard;
755 }
756
757 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
759 self.capabilities.code_scanning = provider;
760 }
761
762 pub(crate) const fn set_workflow_guards_for_test(&mut self, on: bool) {
764 self.capabilities.workflow_guards = Some(on);
765 }
766}
767
768fn invalid_release(message: impl std::fmt::Display) -> RkError {
771 RkError::Usage(format!(
772 "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
773 ))
774}
775
776#[must_use]
798pub fn gate_defaults(
799 profile: &ProfileSnapshot,
800 git: &GitWorkflow,
801) -> Option<(&'static str, &'static str)> {
802 let consuming = profile.forge.as_deref() == Some("github")
803 && profile.release.mode == ReleaseMode::Automatic
804 && profile.release.style == Some(Style::Trunk)
805 && git.integration == Integration::Local;
806 consuming.then_some(("gate", "ci"))
807}
808
809fn gate_answer(
812 record: &manifest::Manifest,
813 pick: impl Fn((&'static str, &'static str)) -> &'static str,
814 held: impl Fn(&manifest::Parameters) -> &String,
815) -> String {
816 let recorded = held(&record.parameters);
817 if recorded.is_empty() {
818 gate_defaults(&record.profile, &record.git)
819 .map_or_else(String::new, |pair| pick(pair).to_owned())
820 } else {
821 recorded.clone()
822 }
823}
824
825fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
827 chain
828 .into_iter()
829 .find_map(|(value, source)| value.map(|value| (value, source)))
830}
831
832#[allow(
840 clippy::too_many_lines,
841 reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
842)]
843pub fn resolve(
844 target: &Utf8Path,
845 flags: &Inputs<'_>,
846 config: Option<&crate::config::Config>,
847 record: Option<&manifest::Manifest>,
848 purpose: Purpose,
849) -> Result<Resolved, RkError> {
850 let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
851 let observed = crate::detect::observe(target.as_std_path());
852 let known_drivers = catalog::known_drivers();
853
854 let (technologies, source) = answered([
856 (
857 (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
858 Source::Flag,
859 ),
860 (
861 config.and_then(|c| c.profile.technologies.clone()),
862 Source::Config,
863 ),
864 (
865 record.map(|r| r.profile.technologies.clone()),
866 Source::Record,
867 ),
868 (
869 Some(
870 observed
871 .technologies
872 .iter()
873 .map(|t| (*t).to_owned())
874 .collect(),
875 ),
876 Source::Observation,
877 ),
878 (None, Source::Default),
879 ])
880 .unwrap_or_else(|| (Vec::new(), Source::Default));
881 let technologies =
882 canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
883 sources.insert("profile.technologies", source);
884
885 let forge_flag = flags
887 .forge
888 .map(|name| canonical_category(name).map_err(RkError::Usage))
889 .transpose()?;
890 let (forge, source) = answered([
891 (forge_flag.map(Some), Source::Flag),
892 (
893 config
894 .and_then(|c| c.profile.forge.clone())
895 .map(|value| if value.is_empty() { None } else { Some(value) }),
896 Source::Config,
897 ),
898 (record.map(|r| r.profile.forge.clone()), Source::Record),
899 (
900 observed.forge.map(|forge| Some(forge.as_str().to_owned())),
901 Source::Observation,
902 ),
903 (Some(None), Source::Default),
904 ])
905 .unwrap_or((None, Source::Default));
906 let forge = forge
907 .map(|name| canonical_category(&name).map_err(RkError::Usage))
908 .transpose()?;
909 sources.insert("profile.forge", source);
910
911 let (repo, source) = answered([
913 (flags.repo.map(str::to_owned), Source::Flag),
914 (
915 config
916 .map(|c| c.project.repo.clone())
917 .filter(|value| !value.is_empty()),
918 Source::Config,
919 ),
920 (
921 record
922 .map(|r| r.parameters.repo.clone())
923 .filter(|value| !value.is_empty()),
924 Source::Record,
925 ),
926 (observed.repo.clone(), Source::Observation),
927 (None, Source::Default),
928 ])
929 .map_or((None, Source::Default), |(value, source)| {
930 (Some(value), source)
931 });
932 sources.insert("project.repo", source);
933
934 let release_bearing: Vec<String> = technologies
937 .iter()
938 .filter(|name| known_drivers.contains(name))
939 .cloned()
940 .collect();
941 let proposal = match release_bearing.as_slice() {
942 [] => Proposal::None,
943 [one] => Proposal::Automatic {
944 driver: one.clone(),
945 },
946 many => Proposal::Ambiguous {
947 drivers: many.to_vec(),
948 },
949 };
950 let proposed_mode = match &proposal {
957 Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
958 Proposal::None => ReleaseMode::None,
959 };
960 let (mode, source) = answered([
961 (flags.release_mode, Source::Flag),
962 (config.and_then(|c| c.profile.release.mode), Source::Config),
963 (record.map(|r| r.profile.release.mode), Source::Record),
964 (Some(proposed_mode), Source::Observation),
965 (None, Source::Default),
966 ])
967 .unwrap_or((ReleaseMode::None, Source::Default));
968 let mode_source = source;
969 sources.insert("profile.release.mode", mode_source);
970 let mode_answered_above = mode_source != Source::Observation;
971 let proposal = (!mode_answered_above).then_some(proposal);
972
973 let driver_flag = flags
977 .release_driver
978 .map(|name| canonical_category(name).map_err(RkError::Usage))
979 .transpose()?;
980 let (driver, driver_source) = answered([
981 (driver_flag, Source::Flag),
982 (
983 config.and_then(|c| c.profile.release.driver.clone()),
984 Source::Config,
985 ),
986 (
987 record.and_then(|r| r.profile.release.driver.clone()),
988 Source::Record,
989 ),
990 (
991 match &proposal {
992 Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
993 Some(driver.clone())
994 }
995 _ => None,
996 },
997 Source::Observation,
998 ),
999 (None, Source::Default),
1000 ])
1001 .map_or((None, Source::Default), |(value, source)| {
1002 (Some(value), source)
1003 });
1004 let (style, style_source) = answered([
1005 (flags.style, Source::Flag),
1006 (config.and_then(|c| c.profile.release.style), Source::Config),
1007 (record.and_then(|r| r.profile.release.style), Source::Record),
1008 (None, Source::Observation),
1009 (
1010 (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
1011 .then_some(Style::Trunk),
1012 Source::Default,
1013 ),
1014 ])
1015 .map_or((None, Source::Default), |(value, source)| {
1016 (Some(value), source)
1017 });
1018 let (line_prefix, prefix_source) = answered([
1019 (None, Source::Flag),
1020 (
1021 config.and_then(|c| c.profile.release.line_prefix.clone()),
1022 Source::Config,
1023 ),
1024 (
1025 record.and_then(|r| r.profile.release.line_prefix.clone()),
1026 Source::Record,
1027 ),
1028 (None, Source::Observation),
1029 (
1030 (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1031 Source::Default,
1032 ),
1033 ])
1034 .map_or((None, Source::Default), |(value, source)| {
1035 (Some(value), source)
1036 });
1037
1038 let reporting = purpose == Purpose::Preview;
1043 let release = match mode {
1044 ReleaseMode::Automatic => {
1045 if let Some(Proposal::Ambiguous { drivers }) = &proposal
1046 && driver.is_none()
1047 && !reporting
1048 {
1049 return Err(RkError::Usage(format!(
1050 "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1051 drivers.join(" and "),
1052 crate::config::CONFIG_PATH
1053 )));
1054 }
1055 if forge.is_none() && !reporting {
1056 let message = observed.host.map_or_else(
1057 || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1058 |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1059 );
1060 return Err(RkError::refusal(
1061 Diagnostic::new(Reason::ForgeUndetected, message)
1062 .expected("a github.com or gitlab remote, or --forge")
1063 .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1064 ));
1065 }
1066 if driver.is_none() && !reporting {
1067 return Err(invalid_release(format!(
1068 "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1069 known_drivers.join("|")
1070 )));
1071 }
1072 if let Some(driver) = &driver
1073 && !technologies.contains(driver)
1074 && !reporting
1075 {
1076 return Err(invalid_release(format!(
1077 "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1078 if technologies.is_empty() {
1079 "empty".to_owned()
1080 } else {
1081 technologies.join(", ")
1082 }
1083 )));
1084 }
1085 let style = match (style, purpose) {
1086 (None, Purpose::Upgrade | Purpose::Adopt) => {
1087 return Err(RkError::Usage(
1088 "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1089 ));
1090 }
1091 (None, _) => Style::Trunk,
1092 (Some(style), _) => style,
1093 };
1094 sources.insert("profile.release.driver", driver_source);
1095 sources.insert("profile.release.style", style_source);
1096 sources.insert("profile.release.line_prefix", prefix_source);
1097 ReleaseIntent {
1098 mode,
1099 driver,
1100 style: Some(style),
1101 line_prefix: Some(
1102 line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1103 ),
1104 }
1105 }
1106 ReleaseMode::External | ReleaseMode::None => {
1107 for (key, present, value_source) in [
1114 ("profile.release.driver", driver.is_some(), driver_source),
1115 ("profile.release.style", style.is_some(), style_source),
1116 (
1117 "profile.release.line_prefix",
1118 line_prefix.is_some(),
1119 prefix_source,
1120 ),
1121 ] {
1122 if present
1123 && matches!(value_source, Source::Flag | Source::Config)
1124 && value_source.rank() <= mode_source.rank()
1125 {
1126 return Err(invalid_release(format!(
1127 "{key} is set while profile.release.mode is {}",
1128 mode.as_str()
1129 )));
1130 }
1131 }
1132 ReleaseIntent {
1133 mode,
1134 driver: None,
1135 style: None,
1136 line_prefix: None,
1137 }
1138 }
1139 };
1140
1141 let adapter_known = forge
1145 .as_deref()
1146 .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1147 let repo = match (forge.is_some(), adapter_known, repo) {
1148 (false, _, _) => String::new(),
1151 (true, false, repo) => repo.unwrap_or_default(),
1152 (true, true, Some(repo)) => repo,
1153 (true, true, None) if purpose == Purpose::Preview => {
1154 crate::projection::REPO_PLACEHOLDER.to_owned()
1155 }
1156 (true, true, None) => return Err(crate::landing::repo_unresolved()),
1157 };
1158
1159 let (trunk, source) = answered([
1161 (flags.trunk.map(str::to_owned), Source::Flag),
1162 (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1163 (record.map(|r| r.git.trunk.clone()), Source::Record),
1164 (None, Source::Observation),
1165 (
1166 Some(crate::config::TRUNK_DEFAULT.to_owned()),
1167 Source::Default,
1168 ),
1169 ])
1170 .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1171 sources.insert("git.trunk", source);
1172 let (checkout_mode, source) = answered([
1173 (flags.checkout_mode, Source::Flag),
1174 (config.and_then(|c| c.git.checkout_mode), Source::Config),
1175 (record.map(|r| r.git.checkout_mode), Source::Record),
1176 (None, Source::Observation),
1177 (
1178 Some(if purpose == Purpose::Adopt {
1179 CheckoutMode::MainWorktree
1180 } else {
1181 CheckoutMode::LinkedWorktree
1182 }),
1183 Source::Default,
1184 ),
1185 ])
1186 .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1187 sources.insert("git.checkout_mode", source);
1188 let (integration, source) = answered([
1193 (flags.integration, Source::Flag),
1194 (config.and_then(|c| c.git.integration), Source::Config),
1195 (record.map(|r| r.git.integration), Source::Record),
1196 (None, Source::Observation),
1197 (
1202 Some(if purpose == Purpose::Adopt {
1203 Integration::Forge
1204 } else {
1205 Integration::Local
1206 }),
1207 Source::Default,
1208 ),
1209 ])
1210 .unwrap_or((Integration::Local, Source::Default));
1211 sources.insert("git.integration", source);
1212
1213 let (nix_packaging, source) = answered([
1215 (flags.nix, Source::Flag),
1216 (
1217 config.and_then(|c| c.capabilities.nix_packaging),
1218 Source::Config,
1219 ),
1220 (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1221 (None, Source::Observation),
1222 (Some(false), Source::Default),
1223 ])
1224 .unwrap_or((false, Source::Default));
1225 sources.insert("capabilities.nix_packaging", source);
1226 let (reporting_policy, source) = answered([
1227 (flags.reporting_policy, Source::Flag),
1228 (
1229 config.and_then(|c| c.capabilities.reporting_policy),
1230 Source::Config,
1231 ),
1232 (
1233 record.map(|r| r.capabilities.reporting_policy),
1234 Source::Record,
1235 ),
1236 (None, Source::Observation),
1237 (
1240 Some(release.mode == ReleaseMode::Automatic),
1241 Source::Default,
1242 ),
1243 ])
1244 .unwrap_or((false, Source::Default));
1245 sources.insert("capabilities.reporting_policy", source);
1246 let (scorecard, source) = answered([
1247 (flags.scorecard, Source::Flag),
1248 (
1249 config.and_then(|c| c.capabilities.scorecard),
1250 Source::Config,
1251 ),
1252 (record.map(|r| r.capabilities.scorecard), Source::Record),
1253 (None, Source::Observation),
1254 (Some(false), Source::Default),
1255 ])
1256 .unwrap_or((false, Source::Default));
1257 sources.insert("capabilities.scorecard", source);
1258 let configured_scanning = config
1259 .and_then(|c| c.capabilities.code_scanning.as_deref())
1260 .map(Provider::parse)
1261 .transpose()?;
1262 let (code_scanning, source) = answered([
1263 (flags.code_scanning, Source::Flag),
1264 (configured_scanning, Source::Config),
1265 (record.map(|r| r.capabilities.code_scanning), Source::Record),
1266 (None, Source::Observation),
1267 (Some(None), Source::Default),
1268 ])
1269 .unwrap_or((None, Source::Default));
1270 sources.insert("capabilities.code_scanning", source);
1271 let (workflow_guards, source) = answered([
1276 (flags.workflow_guards, Source::Flag),
1277 (
1278 config.and_then(|c| c.capabilities.workflow_guards),
1279 Source::Config,
1280 ),
1281 (
1282 record.map(|r| r.capabilities.workflow_guards()),
1283 Source::Record,
1284 ),
1285 (None, Source::Observation),
1286 (Some(false), Source::Default),
1287 ])
1288 .unwrap_or((false, Source::Default));
1289 sources.insert("capabilities.workflow_guards", source);
1290 let (security_contact, source) = answered([
1298 (None, Source::Flag),
1299 (
1300 config.and_then(|c| c.security.contact.clone()),
1301 Source::Config,
1302 ),
1303 (
1304 record.map(|r| r.parameters.security_contact.clone()),
1305 Source::Record,
1306 ),
1307 (None, Source::Observation),
1308 (Some(String::new()), Source::Default),
1309 ])
1310 .unwrap_or((String::new(), Source::Default));
1311 let security_contact =
1312 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1313 sources.insert("security.contact", source);
1314 let (security_response, source) = answered([
1315 (None, Source::Flag),
1316 (
1317 config.and_then(|c| c.security.response.clone()),
1318 Source::Config,
1319 ),
1320 (
1321 record.map(|r| r.parameters.security_response.clone()),
1322 Source::Record,
1323 ),
1324 (None, Source::Observation),
1325 (
1326 Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1327 Source::Default,
1328 ),
1329 ])
1330 .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1331 let security_response =
1332 crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1333 sources.insert("security.response", source);
1334
1335 let gate_default = gate_defaults(
1340 &ProfileSnapshot {
1341 technologies: technologies.clone(),
1342 forge: forge.clone(),
1343 release: release.clone(),
1344 },
1345 &GitWorkflow {
1346 trunk: trunk.clone(),
1347 checkout_mode,
1348 integration,
1349 },
1350 );
1351 let (required_check, source) = answered([
1352 (flags.required_check.map(str::to_owned), Source::Flag),
1353 (
1354 config
1355 .map(|c| c.setup.required_check.clone())
1356 .filter(|name| !name.is_empty()),
1357 Source::Config,
1358 ),
1359 (
1360 record
1361 .map(|r| r.parameters.required_check.clone())
1362 .filter(|name| !name.is_empty()),
1363 Source::Record,
1364 ),
1365 (None, Source::Observation),
1366 (
1367 Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1368 Source::Default,
1369 ),
1370 ])
1371 .unwrap_or((String::new(), Source::Default));
1372 sources.insert("setup.required_check", source);
1373 let (required_workflow, source) = answered([
1374 (flags.required_workflow.map(str::to_owned), Source::Flag),
1375 (
1376 config
1377 .map(|c| c.setup.required_workflow.clone())
1378 .filter(|name| !name.is_empty()),
1379 Source::Config,
1380 ),
1381 (
1382 record
1383 .map(|r| r.parameters.required_workflow.clone())
1384 .filter(|name| !name.is_empty()),
1385 Source::Record,
1386 ),
1387 (None, Source::Observation),
1388 (
1389 Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1390 Source::Default,
1391 ),
1392 ])
1393 .unwrap_or((String::new(), Source::Default));
1394 sources.insert("setup.required_workflow", source);
1395 if forge.as_deref() == Some("gitlab") {
1400 for (flag, supplied) in [
1401 ("--required-check", flags.required_check),
1402 ("--required-workflow", flags.required_workflow),
1403 ] {
1404 if supplied.is_some() {
1405 return Err(RkError::Usage(format!(
1406 "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1407 )));
1408 }
1409 }
1410 }
1411
1412 let mut unknown: Vec<String> = technologies
1413 .iter()
1414 .filter(|name| !known_drivers.contains(name))
1415 .map(|name| format!("technology {name}"))
1416 .collect();
1417 if let Some(name) = &forge
1418 && crate::detect::Forge::parse(name).is_none()
1419 {
1420 unknown.push(format!("forge {name}"));
1421 }
1422
1423 Ok(Resolved {
1424 params: Params {
1425 profile: ProfileSnapshot {
1426 technologies,
1427 forge,
1428 release,
1429 },
1430 git: GitWorkflow {
1431 trunk,
1432 checkout_mode,
1433 integration,
1434 },
1435 capabilities: CapabilityRequests {
1436 nix_packaging,
1437 reporting_policy,
1438 scorecard,
1439 code_scanning,
1440 workflow_guards: Some(workflow_guards),
1441 },
1442 repo,
1443 security_contact,
1444 security_response,
1445 required_check,
1446 required_workflow,
1447 },
1448 sources,
1449 unknown,
1450 proposal,
1451 })
1452}