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}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
138#[serde(rename_all = "lowercase")]
139pub enum Source {
140 Flag,
142 Config,
144 Record,
146 Observation,
148 Default,
150}
151
152impl Source {
153 #[must_use]
159 pub const fn rank(self) -> u8 {
160 match self {
161 Self::Flag => 0,
162 Self::Config => 1,
163 Self::Record => 2,
164 Self::Observation => 3,
165 Self::Default => 4,
166 }
167 }
168
169 #[must_use]
171 pub const fn as_str(self) -> &'static str {
172 match self {
173 Self::Flag => "flag",
174 Self::Config => "config",
175 Self::Record => "record",
176 Self::Observation => "observation",
177 Self::Default => "default",
178 }
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "kebab-case", tag = "state")]
186pub enum Proposal {
187 None,
189 Automatic {
191 driver: String,
193 },
194 Ambiguous {
197 drivers: Vec<String>,
199 },
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct Params {
208 profile: ProfileSnapshot,
209 git: GitWorkflow,
210 capabilities: CapabilityRequests,
211 repo: String,
212 security_contact: String,
213 security_response: String,
214 required_check: String,
215 required_workflow: String,
216}
217
218#[derive(Default)]
220pub struct Inputs<'a> {
221 pub technologies: &'a [String],
224 pub forge: Option<&'a str>,
226 pub repo: Option<&'a str>,
228 pub release_mode: Option<ReleaseMode>,
230 pub release_driver: Option<&'a str>,
232 pub style: Option<Style>,
234 pub trunk: Option<&'a str>,
236 pub checkout_mode: Option<CheckoutMode>,
238 pub integration: Option<Integration>,
240 pub required_check: Option<&'a str>,
242 pub required_workflow: Option<&'a str>,
245 pub nix: Option<bool>,
247 pub reporting_policy: Option<bool>,
249 pub scorecard: Option<bool>,
251 pub code_scanning: Option<Option<Provider>>,
254}
255
256#[derive(Clone, Copy, PartialEq, Eq)]
258pub enum Purpose {
259 Init,
261 Preview,
264 Upgrade,
266 Adopt,
268}
269
270#[derive(Debug, Clone)]
272pub struct Resolved {
273 pub params: Params,
275 pub sources: BTreeMap<&'static str, Source>,
277 pub unknown: Vec<String>,
279 pub proposal: Option<Proposal>,
282}
283
284pub fn canonical_category(raw: &str) -> Result<String, String> {
294 let lowered = raw.trim().to_ascii_lowercase();
295 let shaped = lowered
296 .chars()
297 .next()
298 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
299 && lowered
300 .chars()
301 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
302 if shaped {
303 Ok(lowered)
304 } else {
305 Err(format!(
306 "{raw:?} is not a category name; one is lowercase letters and digits with hyphens inside, as [a-z0-9][a-z0-9-]*"
307 ))
308 }
309}
310
311pub fn canonical_list(key: &str, raw: &[String]) -> Result<Vec<String>, String> {
317 let mut out = Vec::with_capacity(raw.len());
318 for value in raw {
319 let name = canonical_category(value).map_err(|reason| format!("{key}: {reason}"))?;
320 if out.contains(&name) {
321 return Err(format!("{key} names {name} twice; each technology once"));
322 }
323 out.push(name);
324 }
325 out.sort();
326 Ok(out)
327}
328
329fn push_shell_word(out: &mut String, value: &str) {
332 let inert = !value.is_empty()
333 && value.bytes().all(|byte| {
334 byte.is_ascii_alphanumeric()
335 || matches!(
336 byte,
337 b'_' | b'@' | b'%' | b'+' | b'=' | b':' | b',' | b'.' | b'/' | b'-'
338 )
339 });
340 if inert {
341 out.push_str(value);
342 return;
343 }
344 out.push('\'');
345 out.push_str(&value.replace('\'', "'\"'\"'"));
346 out.push('\'');
347}
348
349impl Params {
350 #[must_use]
353 pub fn from_record(record: &manifest::Manifest) -> Self {
354 Self {
355 profile: record.profile.clone(),
356 git: record.git.clone(),
357 capabilities: record.capabilities.clone(),
358 repo: record.parameters.repo.clone(),
359 security_contact: record.parameters.security_contact.clone(),
360 security_response: record.parameters.security_response.clone(),
361 required_check: gate_answer(
364 record,
365 |(check, _)| check,
366 |parameters| ¶meters.required_check,
367 ),
368 required_workflow: gate_answer(
369 record,
370 |(_, workflow)| workflow,
371 |parameters| ¶meters.required_workflow,
372 ),
373 }
374 }
375
376 pub fn resolve(
384 target: &Utf8Path,
385 flags: &Inputs<'_>,
386 config: Option<&crate::config::Config>,
387 record: Option<&manifest::Manifest>,
388 purpose: Purpose,
389 ) -> Result<Self, RkError> {
390 resolve(target, flags, config, record, purpose).map(|resolved| resolved.params)
391 }
392
393 #[must_use]
395 pub const fn profile(&self) -> &ProfileSnapshot {
396 &self.profile
397 }
398
399 #[must_use]
401 pub const fn git(&self) -> &GitWorkflow {
402 &self.git
403 }
404
405 #[must_use]
407 pub const fn capabilities(&self) -> &CapabilityRequests {
408 &self.capabilities
409 }
410
411 #[must_use]
413 pub fn technologies(&self) -> &[String] {
414 &self.profile.technologies
415 }
416
417 #[must_use]
419 pub fn forge(&self) -> Option<&str> {
420 self.profile.forge.as_deref()
421 }
422
423 #[must_use]
425 pub const fn release_mode(&self) -> ReleaseMode {
426 self.profile.release.mode
427 }
428
429 #[must_use]
431 pub fn driver(&self) -> Option<&str> {
432 self.profile.release.driver.as_deref()
433 }
434
435 #[must_use]
437 pub const fn style(&self) -> Option<Style> {
438 self.profile.release.style
439 }
440
441 #[must_use]
443 pub const fn nix_packaging(&self) -> bool {
444 self.capabilities.nix_packaging
445 }
446
447 #[must_use]
449 pub const fn reporting_policy(&self) -> bool {
450 self.capabilities.reporting_policy
451 }
452
453 #[must_use]
455 pub const fn scorecard(&self) -> bool {
456 self.capabilities.scorecard
457 }
458
459 #[must_use]
461 pub const fn code_scanning(&self) -> Option<Provider> {
462 self.capabilities.code_scanning
463 }
464
465 #[must_use]
468 pub fn repo(&self) -> &str {
469 &self.repo
470 }
471
472 #[must_use]
474 pub const fn checkout_mode(&self) -> CheckoutMode {
475 self.git.checkout_mode
476 }
477
478 #[must_use]
480 pub const fn integration(&self) -> Integration {
481 self.git.integration
482 }
483
484 #[must_use]
486 pub fn trunk(&self) -> &str {
487 &self.git.trunk
488 }
489
490 #[must_use]
493 pub fn line_prefix(&self) -> &str {
494 self.profile
495 .release
496 .line_prefix
497 .as_deref()
498 .unwrap_or(crate::config::LINE_PREFIX_DEFAULT)
499 }
500
501 #[must_use]
504 pub fn security_contact(&self) -> &str {
505 &self.security_contact
506 }
507
508 #[must_use]
510 pub fn security_response(&self) -> &str {
511 &self.security_response
512 }
513
514 #[must_use]
517 pub fn required_check(&self) -> &str {
518 &self.required_check
519 }
520
521 #[must_use]
524 pub fn required_workflow(&self) -> &str {
525 &self.required_workflow
526 }
527
528 #[must_use]
532 pub fn canonical_flags(&self) -> String {
533 let mut out = String::new();
534 for technology in &self.profile.technologies {
535 out.push_str(" --technology ");
536 out.push_str(technology);
537 }
538 if let Some(forge) = &self.profile.forge {
539 out.push_str(" --forge ");
540 out.push_str(forge);
541 }
542 if !self.repo.is_empty() && self.repo != crate::projection::REPO_PLACEHOLDER {
546 out.push_str(" --repo ");
547 out.push_str(&self.repo);
548 }
549 out.push_str(" --release-mode ");
550 out.push_str(self.profile.release.mode.as_str());
551 if let Some(driver) = &self.profile.release.driver {
552 out.push_str(" --release-driver ");
553 out.push_str(driver);
554 }
555 if let Some(style) = self.profile.release.style {
556 out.push_str(" --release-style ");
557 out.push_str(style.as_str());
558 }
559 out.push_str(" --trunk ");
560 out.push_str(&self.git.trunk);
561 out.push_str(" --checkout-mode ");
562 out.push_str(self.git.checkout_mode.as_str());
563 out.push_str(" --integration ");
564 out.push_str(self.git.integration.as_str());
565 if !self.required_check.is_empty() {
569 out.push_str(" --required-check ");
570 push_shell_word(&mut out, &self.required_check);
571 }
572 if !self.required_workflow.is_empty() {
573 out.push_str(" --required-workflow ");
574 push_shell_word(&mut out, &self.required_workflow);
575 }
576 out
577 }
578
579 #[must_use]
585 pub fn capability_flags(&self) -> String {
586 let mut out = String::new();
587 if self.capabilities.nix_packaging {
588 out.push_str(" --nix-packaging");
589 }
590 if self.capabilities.reporting_policy {
591 out.push_str(" --reporting-policy");
592 }
593 if self.capabilities.scorecard {
594 out.push_str(" --scorecard");
595 }
596 out.push_str(" --code-scanning ");
603 out.push_str(
604 self.capabilities
605 .code_scanning
606 .map_or("off", Provider::as_str),
607 );
608 out
609 }
610
611 #[must_use]
618 pub fn capability_toggles(&self) -> String {
619 let word = |on: bool| if on { "on" } else { "off" };
620 format!(
621 " --nix-packaging {} --reporting-policy {} --scorecard {} --code-scanning {}",
622 word(self.capabilities.nix_packaging),
623 word(self.capabilities.reporting_policy),
624 word(self.capabilities.scorecard),
625 self.capabilities
626 .code_scanning
627 .map_or("off", Provider::as_str)
628 )
629 }
630}
631
632#[cfg(test)]
633impl Params {
634 pub(crate) fn for_test(repo: &str, style: Option<Style>) -> Self {
639 Self {
640 profile: ProfileSnapshot {
641 technologies: vec!["rust".to_owned()],
642 forge: Some("github".to_owned()),
643 release: ReleaseIntent {
644 mode: ReleaseMode::Automatic,
645 driver: Some("rust".to_owned()),
646 style,
647 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
648 },
649 },
650 git: GitWorkflow {
651 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
652 checkout_mode: CheckoutMode::LinkedWorktree,
653 integration: Integration::Local,
654 },
655 capabilities: CapabilityRequests {
656 nix_packaging: false,
657 reporting_policy: true,
658 scorecard: false,
659 code_scanning: None,
660 },
661 repo: repo.to_owned(),
662 security_contact: String::new(),
663 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
664 required_check: "gate".to_owned(),
665 required_workflow: "ci".to_owned(),
666 }
667 }
668
669 pub(crate) fn for_test_security(contact: &str, response: &str) -> Self {
671 Self {
672 security_contact: contact.to_owned(),
673 security_response: response.to_owned(),
674 ..Self::for_test("acme/widget", Some(Style::Trunk))
675 }
676 }
677
678 pub(crate) fn for_test_release_less(
681 technologies: &[&str],
682 forge: Option<&str>,
683 mode: ReleaseMode,
684 ) -> Self {
685 let mut params = Self::for_test("acme/widget", None);
686 params.profile.technologies = technologies.iter().map(|t| (*t).to_owned()).collect();
687 params.profile.forge = forge.map(str::to_owned);
688 params.profile.release = ReleaseIntent {
689 mode,
690 driver: None,
691 style: None,
692 line_prefix: None,
693 };
694 params.capabilities.reporting_policy = false;
695 if forge.is_none() {
696 params.repo = String::new();
697 }
698 params
699 }
700
701 pub(crate) fn set_pair_for_test(&mut self, driver: &str, forge: &str) {
703 self.profile.technologies = vec![driver.to_owned()];
704 self.profile.release.driver = Some(driver.to_owned());
705 self.profile.forge = Some(forge.to_owned());
706 }
707
708 pub(crate) const fn set_checkout_mode_for_test(&mut self, mode: CheckoutMode) {
710 self.git.checkout_mode = mode;
711 }
712
713 pub(crate) const fn set_integration_for_test(&mut self, mode: Integration) {
715 self.git.integration = mode;
716 }
717
718 pub(crate) const fn set_nix_for_test(&mut self, nix: bool) {
720 self.capabilities.nix_packaging = nix;
721 }
722
723 pub(crate) const fn set_scorecard_for_test(&mut self, scorecard: bool) {
725 self.capabilities.scorecard = scorecard;
726 }
727
728 pub(crate) const fn set_code_scanning_for_test(&mut self, provider: Option<Provider>) {
730 self.capabilities.code_scanning = provider;
731 }
732}
733
734fn invalid_release(message: impl std::fmt::Display) -> RkError {
737 RkError::Usage(format!(
738 "{message}; an automatic release names a driver among profile.technologies and a style, and an external or none release names neither"
739 ))
740}
741
742#[must_use]
764pub fn gate_defaults(
765 profile: &ProfileSnapshot,
766 git: &GitWorkflow,
767) -> Option<(&'static str, &'static str)> {
768 let consuming = profile.forge.as_deref() == Some("github")
769 && profile.release.mode == ReleaseMode::Automatic
770 && profile.release.style == Some(Style::Trunk)
771 && git.integration == Integration::Local;
772 consuming.then_some(("gate", "ci"))
773}
774
775fn gate_answer(
778 record: &manifest::Manifest,
779 pick: impl Fn((&'static str, &'static str)) -> &'static str,
780 held: impl Fn(&manifest::Parameters) -> &String,
781) -> String {
782 let recorded = held(&record.parameters);
783 if recorded.is_empty() {
784 gate_defaults(&record.profile, &record.git)
785 .map_or_else(String::new, |pair| pick(pair).to_owned())
786 } else {
787 recorded.clone()
788 }
789}
790
791fn answered<T>(chain: [(Option<T>, Source); 5]) -> Option<(T, Source)> {
793 chain
794 .into_iter()
795 .find_map(|(value, source)| value.map(|value| (value, source)))
796}
797
798#[allow(
806 clippy::too_many_lines,
807 reason = "the resolution is one precedence walk per field, and splitting it would hide that every field walks the same chain"
808)]
809pub fn resolve(
810 target: &Utf8Path,
811 flags: &Inputs<'_>,
812 config: Option<&crate::config::Config>,
813 record: Option<&manifest::Manifest>,
814 purpose: Purpose,
815) -> Result<Resolved, RkError> {
816 let mut sources: BTreeMap<&'static str, Source> = BTreeMap::new();
817 let observed = crate::detect::observe(target.as_std_path());
818 let known_drivers = catalog::known_drivers();
819
820 let (technologies, source) = answered([
822 (
823 (!flags.technologies.is_empty()).then(|| flags.technologies.to_vec()),
824 Source::Flag,
825 ),
826 (
827 config.and_then(|c| c.profile.technologies.clone()),
828 Source::Config,
829 ),
830 (
831 record.map(|r| r.profile.technologies.clone()),
832 Source::Record,
833 ),
834 (
835 Some(
836 observed
837 .technologies
838 .iter()
839 .map(|t| (*t).to_owned())
840 .collect(),
841 ),
842 Source::Observation,
843 ),
844 (None, Source::Default),
845 ])
846 .unwrap_or_else(|| (Vec::new(), Source::Default));
847 let technologies =
848 canonical_list("profile.technologies", &technologies).map_err(RkError::Usage)?;
849 sources.insert("profile.technologies", source);
850
851 let forge_flag = flags
853 .forge
854 .map(|name| canonical_category(name).map_err(RkError::Usage))
855 .transpose()?;
856 let (forge, source) = answered([
857 (forge_flag.map(Some), Source::Flag),
858 (
859 config
860 .and_then(|c| c.profile.forge.clone())
861 .map(|value| if value.is_empty() { None } else { Some(value) }),
862 Source::Config,
863 ),
864 (record.map(|r| r.profile.forge.clone()), Source::Record),
865 (
866 observed.forge.map(|forge| Some(forge.as_str().to_owned())),
867 Source::Observation,
868 ),
869 (Some(None), Source::Default),
870 ])
871 .unwrap_or((None, Source::Default));
872 let forge = forge
873 .map(|name| canonical_category(&name).map_err(RkError::Usage))
874 .transpose()?;
875 sources.insert("profile.forge", source);
876
877 let (repo, source) = answered([
879 (flags.repo.map(str::to_owned), Source::Flag),
880 (
881 config
882 .map(|c| c.project.repo.clone())
883 .filter(|value| !value.is_empty()),
884 Source::Config,
885 ),
886 (
887 record
888 .map(|r| r.parameters.repo.clone())
889 .filter(|value| !value.is_empty()),
890 Source::Record,
891 ),
892 (observed.repo.clone(), Source::Observation),
893 (None, Source::Default),
894 ])
895 .map_or((None, Source::Default), |(value, source)| {
896 (Some(value), source)
897 });
898 sources.insert("project.repo", source);
899
900 let release_bearing: Vec<String> = technologies
903 .iter()
904 .filter(|name| known_drivers.contains(name))
905 .cloned()
906 .collect();
907 let proposal = match release_bearing.as_slice() {
908 [] => Proposal::None,
909 [one] => Proposal::Automatic {
910 driver: one.clone(),
911 },
912 many => Proposal::Ambiguous {
913 drivers: many.to_vec(),
914 },
915 };
916 let proposed_mode = match &proposal {
923 Proposal::Automatic { .. } | Proposal::Ambiguous { .. } => ReleaseMode::Automatic,
924 Proposal::None => ReleaseMode::None,
925 };
926 let (mode, source) = answered([
927 (flags.release_mode, Source::Flag),
928 (config.and_then(|c| c.profile.release.mode), Source::Config),
929 (record.map(|r| r.profile.release.mode), Source::Record),
930 (Some(proposed_mode), Source::Observation),
931 (None, Source::Default),
932 ])
933 .unwrap_or((ReleaseMode::None, Source::Default));
934 let mode_source = source;
935 sources.insert("profile.release.mode", mode_source);
936 let mode_answered_above = mode_source != Source::Observation;
937 let proposal = (!mode_answered_above).then_some(proposal);
938
939 let driver_flag = flags
943 .release_driver
944 .map(|name| canonical_category(name).map_err(RkError::Usage))
945 .transpose()?;
946 let (driver, driver_source) = answered([
947 (driver_flag, Source::Flag),
948 (
949 config.and_then(|c| c.profile.release.driver.clone()),
950 Source::Config,
951 ),
952 (
953 record.and_then(|r| r.profile.release.driver.clone()),
954 Source::Record,
955 ),
956 (
957 match &proposal {
958 Some(Proposal::Automatic { driver }) if mode == ReleaseMode::Automatic => {
959 Some(driver.clone())
960 }
961 _ => None,
962 },
963 Source::Observation,
964 ),
965 (None, Source::Default),
966 ])
967 .map_or((None, Source::Default), |(value, source)| {
968 (Some(value), source)
969 });
970 let (style, style_source) = answered([
971 (flags.style, Source::Flag),
972 (config.and_then(|c| c.profile.release.style), Source::Config),
973 (record.and_then(|r| r.profile.release.style), Source::Record),
974 (None, Source::Observation),
975 (
976 (mode == ReleaseMode::Automatic && matches!(purpose, Purpose::Init | Purpose::Preview))
977 .then_some(Style::Trunk),
978 Source::Default,
979 ),
980 ])
981 .map_or((None, Source::Default), |(value, source)| {
982 (Some(value), source)
983 });
984 let (line_prefix, prefix_source) = answered([
985 (None, Source::Flag),
986 (
987 config.and_then(|c| c.profile.release.line_prefix.clone()),
988 Source::Config,
989 ),
990 (
991 record.and_then(|r| r.profile.release.line_prefix.clone()),
992 Source::Record,
993 ),
994 (None, Source::Observation),
995 (
996 (mode == ReleaseMode::Automatic).then(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
997 Source::Default,
998 ),
999 ])
1000 .map_or((None, Source::Default), |(value, source)| {
1001 (Some(value), source)
1002 });
1003
1004 let reporting = purpose == Purpose::Preview;
1009 let release = match mode {
1010 ReleaseMode::Automatic => {
1011 if let Some(Proposal::Ambiguous { drivers }) = &proposal
1012 && driver.is_none()
1013 && !reporting
1014 {
1015 return Err(RkError::Usage(format!(
1016 "the target carries more than one release-bearing technology, {}, and nothing names the driver; pass --release-driver <name>, or set profile.release.driver in {}",
1017 drivers.join(" and "),
1018 crate::config::CONFIG_PATH
1019 )));
1020 }
1021 if forge.is_none() && !reporting {
1022 let message = observed.host.map_or_else(
1023 || "no forge detected: the target has no origin remote, and an automatic release needs one".to_owned(),
1024 |host| format!("no forge detected: the host {host} is not recognized, and an automatic release needs one"),
1025 );
1026 return Err(RkError::refusal(
1027 Diagnostic::new(Reason::ForgeUndetected, message)
1028 .expected("a github.com or gitlab remote, or --forge")
1029 .action("pass --forge <github|gitlab>, or --release-mode none for a project that releases nothing"),
1030 ));
1031 }
1032 if driver.is_none() && !reporting {
1033 return Err(invalid_release(format!(
1034 "profile.release.mode is automatic and no driver is named; pass --release-driver <{}>",
1035 known_drivers.join("|")
1036 )));
1037 }
1038 if let Some(driver) = &driver
1039 && !technologies.contains(driver)
1040 && !reporting
1041 {
1042 return Err(invalid_release(format!(
1043 "profile.release.driver names {driver}, which profile.technologies does not carry ({})",
1044 if technologies.is_empty() {
1045 "empty".to_owned()
1046 } else {
1047 technologies.join(", ")
1048 }
1049 )));
1050 }
1051 let style = match (style, purpose) {
1052 (None, Purpose::Upgrade | Purpose::Adopt) => {
1053 return Err(RkError::Usage(
1054 "the target carries no style parameter; set profile.release.style in .release-kit/config.toml or pass --release-style <trunk|lines>".into(),
1055 ));
1056 }
1057 (None, _) => Style::Trunk,
1058 (Some(style), _) => style,
1059 };
1060 sources.insert("profile.release.driver", driver_source);
1061 sources.insert("profile.release.style", style_source);
1062 sources.insert("profile.release.line_prefix", prefix_source);
1063 ReleaseIntent {
1064 mode,
1065 driver,
1066 style: Some(style),
1067 line_prefix: Some(
1068 line_prefix.unwrap_or_else(|| crate::config::LINE_PREFIX_DEFAULT.to_owned()),
1069 ),
1070 }
1071 }
1072 ReleaseMode::External | ReleaseMode::None => {
1073 for (key, present, value_source) in [
1080 ("profile.release.driver", driver.is_some(), driver_source),
1081 ("profile.release.style", style.is_some(), style_source),
1082 (
1083 "profile.release.line_prefix",
1084 line_prefix.is_some(),
1085 prefix_source,
1086 ),
1087 ] {
1088 if present
1089 && matches!(value_source, Source::Flag | Source::Config)
1090 && value_source.rank() <= mode_source.rank()
1091 {
1092 return Err(invalid_release(format!(
1093 "{key} is set while profile.release.mode is {}",
1094 mode.as_str()
1095 )));
1096 }
1097 }
1098 ReleaseIntent {
1099 mode,
1100 driver: None,
1101 style: None,
1102 line_prefix: None,
1103 }
1104 }
1105 };
1106
1107 let adapter_known = forge
1111 .as_deref()
1112 .is_some_and(|name| crate::detect::Forge::parse(name).is_some());
1113 let repo = match (forge.is_some(), adapter_known, repo) {
1114 (false, _, _) => String::new(),
1117 (true, false, repo) => repo.unwrap_or_default(),
1118 (true, true, Some(repo)) => repo,
1119 (true, true, None) if purpose == Purpose::Preview => {
1120 crate::projection::REPO_PLACEHOLDER.to_owned()
1121 }
1122 (true, true, None) => return Err(crate::landing::repo_unresolved()),
1123 };
1124
1125 let (trunk, source) = answered([
1127 (flags.trunk.map(str::to_owned), Source::Flag),
1128 (config.and_then(|c| c.git.trunk.clone()), Source::Config),
1129 (record.map(|r| r.git.trunk.clone()), Source::Record),
1130 (None, Source::Observation),
1131 (
1132 Some(crate::config::TRUNK_DEFAULT.to_owned()),
1133 Source::Default,
1134 ),
1135 ])
1136 .unwrap_or_else(|| (crate::config::TRUNK_DEFAULT.to_owned(), Source::Default));
1137 sources.insert("git.trunk", source);
1138 let (checkout_mode, source) = answered([
1139 (flags.checkout_mode, Source::Flag),
1140 (config.and_then(|c| c.git.checkout_mode), Source::Config),
1141 (record.map(|r| r.git.checkout_mode), Source::Record),
1142 (None, Source::Observation),
1143 (
1144 Some(if purpose == Purpose::Adopt {
1145 CheckoutMode::MainWorktree
1146 } else {
1147 CheckoutMode::LinkedWorktree
1148 }),
1149 Source::Default,
1150 ),
1151 ])
1152 .unwrap_or((CheckoutMode::LinkedWorktree, Source::Default));
1153 sources.insert("git.checkout_mode", source);
1154 let (integration, source) = answered([
1159 (flags.integration, Source::Flag),
1160 (config.and_then(|c| c.git.integration), Source::Config),
1161 (record.map(|r| r.git.integration), Source::Record),
1162 (None, Source::Observation),
1163 (
1168 Some(if purpose == Purpose::Adopt {
1169 Integration::Forge
1170 } else {
1171 Integration::Local
1172 }),
1173 Source::Default,
1174 ),
1175 ])
1176 .unwrap_or((Integration::Local, Source::Default));
1177 sources.insert("git.integration", source);
1178
1179 let (nix_packaging, source) = answered([
1181 (flags.nix, Source::Flag),
1182 (
1183 config.and_then(|c| c.capabilities.nix_packaging),
1184 Source::Config,
1185 ),
1186 (record.map(|r| r.capabilities.nix_packaging), Source::Record),
1187 (None, Source::Observation),
1188 (Some(false), Source::Default),
1189 ])
1190 .unwrap_or((false, Source::Default));
1191 sources.insert("capabilities.nix_packaging", source);
1192 let (reporting_policy, source) = answered([
1193 (flags.reporting_policy, Source::Flag),
1194 (
1195 config.and_then(|c| c.capabilities.reporting_policy),
1196 Source::Config,
1197 ),
1198 (
1199 record.map(|r| r.capabilities.reporting_policy),
1200 Source::Record,
1201 ),
1202 (None, Source::Observation),
1203 (
1206 Some(release.mode == ReleaseMode::Automatic),
1207 Source::Default,
1208 ),
1209 ])
1210 .unwrap_or((false, Source::Default));
1211 sources.insert("capabilities.reporting_policy", source);
1212 let (scorecard, source) = answered([
1213 (flags.scorecard, Source::Flag),
1214 (
1215 config.and_then(|c| c.capabilities.scorecard),
1216 Source::Config,
1217 ),
1218 (record.map(|r| r.capabilities.scorecard), Source::Record),
1219 (None, Source::Observation),
1220 (Some(false), Source::Default),
1221 ])
1222 .unwrap_or((false, Source::Default));
1223 sources.insert("capabilities.scorecard", source);
1224 let configured_scanning = config
1225 .and_then(|c| c.capabilities.code_scanning.as_deref())
1226 .map(Provider::parse)
1227 .transpose()?;
1228 let (code_scanning, source) = answered([
1229 (flags.code_scanning, Source::Flag),
1230 (configured_scanning, Source::Config),
1231 (record.map(|r| r.capabilities.code_scanning), Source::Record),
1232 (None, Source::Observation),
1233 (Some(None), Source::Default),
1234 ])
1235 .unwrap_or((None, Source::Default));
1236 sources.insert("capabilities.code_scanning", source);
1237 let (security_contact, source) = answered([
1245 (None, Source::Flag),
1246 (
1247 config.and_then(|c| c.security.contact.clone()),
1248 Source::Config,
1249 ),
1250 (
1251 record.map(|r| r.parameters.security_contact.clone()),
1252 Source::Record,
1253 ),
1254 (None, Source::Observation),
1255 (Some(String::new()), Source::Default),
1256 ])
1257 .unwrap_or((String::new(), Source::Default));
1258 let security_contact =
1259 crate::config::canonical_contact(&security_contact).map_err(crate::config::invalid)?;
1260 sources.insert("security.contact", source);
1261 let (security_response, source) = answered([
1262 (None, Source::Flag),
1263 (
1264 config.and_then(|c| c.security.response.clone()),
1265 Source::Config,
1266 ),
1267 (
1268 record.map(|r| r.parameters.security_response.clone()),
1269 Source::Record,
1270 ),
1271 (None, Source::Observation),
1272 (
1273 Some(crate::config::RESPONSE_DEFAULT.to_owned()),
1274 Source::Default,
1275 ),
1276 ])
1277 .unwrap_or_else(|| (crate::config::RESPONSE_DEFAULT.to_owned(), Source::Default));
1278 let security_response =
1279 crate::config::canonical_response(&security_response).map_err(crate::config::invalid)?;
1280 sources.insert("security.response", source);
1281
1282 let gate_default = gate_defaults(
1287 &ProfileSnapshot {
1288 technologies: technologies.clone(),
1289 forge: forge.clone(),
1290 release: release.clone(),
1291 },
1292 &GitWorkflow {
1293 trunk: trunk.clone(),
1294 checkout_mode,
1295 integration,
1296 },
1297 );
1298 let (required_check, source) = answered([
1299 (flags.required_check.map(str::to_owned), Source::Flag),
1300 (
1301 config
1302 .map(|c| c.setup.required_check.clone())
1303 .filter(|name| !name.is_empty()),
1304 Source::Config,
1305 ),
1306 (
1307 record
1308 .map(|r| r.parameters.required_check.clone())
1309 .filter(|name| !name.is_empty()),
1310 Source::Record,
1311 ),
1312 (None, Source::Observation),
1313 (
1314 Some(gate_default.map_or_else(String::new, |(check, _)| check.to_owned())),
1315 Source::Default,
1316 ),
1317 ])
1318 .unwrap_or((String::new(), Source::Default));
1319 sources.insert("setup.required_check", source);
1320 let (required_workflow, source) = answered([
1321 (flags.required_workflow.map(str::to_owned), Source::Flag),
1322 (
1323 config
1324 .map(|c| c.setup.required_workflow.clone())
1325 .filter(|name| !name.is_empty()),
1326 Source::Config,
1327 ),
1328 (
1329 record
1330 .map(|r| r.parameters.required_workflow.clone())
1331 .filter(|name| !name.is_empty()),
1332 Source::Record,
1333 ),
1334 (None, Source::Observation),
1335 (
1336 Some(gate_default.map_or_else(String::new, |(_, workflow)| workflow.to_owned())),
1337 Source::Default,
1338 ),
1339 ])
1340 .unwrap_or((String::new(), Source::Default));
1341 sources.insert("setup.required_workflow", source);
1342 if forge.as_deref() == Some("gitlab") {
1347 for (flag, supplied) in [
1348 ("--required-check", flags.required_check),
1349 ("--required-workflow", flags.required_workflow),
1350 ] {
1351 if supplied.is_some() {
1352 return Err(RkError::Usage(format!(
1353 "{flag} is not a GitLab answer: the forge requires the whole pipeline through one project setting and names no individual check"
1354 )));
1355 }
1356 }
1357 }
1358
1359 let mut unknown: Vec<String> = technologies
1360 .iter()
1361 .filter(|name| !known_drivers.contains(name))
1362 .map(|name| format!("technology {name}"))
1363 .collect();
1364 if let Some(name) = &forge
1365 && crate::detect::Forge::parse(name).is_none()
1366 {
1367 unknown.push(format!("forge {name}"));
1368 }
1369
1370 Ok(Resolved {
1371 params: Params {
1372 profile: ProfileSnapshot {
1373 technologies,
1374 forge,
1375 release,
1376 },
1377 git: GitWorkflow {
1378 trunk,
1379 checkout_mode,
1380 integration,
1381 },
1382 capabilities: CapabilityRequests {
1383 nix_packaging,
1384 reporting_policy,
1385 scorecard,
1386 code_scanning,
1387 },
1388 repo,
1389 security_contact,
1390 security_response,
1391 required_check,
1392 required_workflow,
1393 },
1394 sources,
1395 unknown,
1396 proposal,
1397 })
1398}