1mod path_translation;
8
9pub use path_translation::{
10 PathTranslation, PathTranslations, TranslationDirection, TranslationOrigin,
11};
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::fmt;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::str::FromStr;
18
19use regex::Regex;
20use serde::{Deserialize, Deserializer, Serialize};
21
22use crate::errors::{ConfigError, NameError};
23use crate::model::names::{ApplicationName, GitRef, ProjectName, SeriesName, TagGlob, TagName};
24use crate::model::pin::{self, Pin, PinSelect, PinSpec};
25use crate::store::SnapshotStore;
26use crate::suites::rules::validate_template_placeholders;
27use crate::suites::{ExtraRule, ExtraRuleTrigger, LineMatch};
28
29pub const CONFIG_VERSION: u32 = 1;
30
31const DEFAULT_SCAN_PATHS: &[&str] = &["src/**/*.erl", "src/**/*.ex", "include/**/*.hrl"];
32const DEFAULT_SNAPSHOT_DIR: &str = "snapshots";
33const DEFAULT_FALLBACK_BRANCH: &str = "main";
34
35const ERLANG_OTP_APP_ROOTS: &[&str] = &["lib/*", "erts/preloaded"];
36const ERLANG_OTP_EXCLUDE_APPS: &[&str] = &[
37 "odbc",
38 "snmp",
39 "ssh",
40 "tftp",
41 "ftp",
42 "wx",
43 "megaco",
44 "edoc",
45 "jinterface",
46 "diameter",
47];
48const ERLANG_OTP_EXCLUDED_SUBDIRS: &[&str] = &["doc", "example", "examples", "test"];
49const ERLANG_OTP_TAG_PATTERN: &str = "OTP-*";
50const ERLANG_OTP_MIN_TAG: &str = "OTP-26.0";
51
52const DEFAULT_EXCLUDE_TAG_MARKERS: &[&str] = &["-rc", "-alpha", "-beta", "-pre"];
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct ConfigFile {
59 #[serde(default = "default_config_version")]
60 pub config_version: u32,
61
62 #[serde(default)]
63 pub defaults: DefaultsRaw,
64
65 #[serde(default)]
66 pub cache: CacheRaw,
67
68 #[serde(default, rename = "project")]
69 pub projects: Vec<ProjectRaw>,
70
71 #[serde(default, rename = "series")]
72 pub series: Vec<SeriesRaw>,
73
74 #[serde(default, rename = "suite_rule")]
75 pub suite_rules: Vec<SuiteRuleRaw>,
76
77 #[serde(default, rename = "path_translation")]
78 pub path_translations: Vec<PathTranslationRaw>,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct PathTranslationRaw {
84 pub name: String,
85 #[serde(default = "default_translation_direction")]
86 pub direction: String,
87 pub source_prefix: String,
88 pub target_prefix: String,
89}
90
91fn default_translation_direction() -> String {
92 "source_to_target".to_owned()
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct SuiteRuleRaw {
101 pub name: Option<String>,
102 pub when_modified_path_matches: String,
103 #[serde(default)]
104 pub when_modified_line_matches: Option<String>,
105 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
106 pub include_suite: Vec<String>,
107 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
108 pub also: Vec<String>,
109 #[serde(default)]
110 pub include_suite_for_dep_modules: bool,
111}
112
113#[derive(Deserialize)]
114#[serde(untagged)]
115enum StringOrVec {
116 One(String),
117 Many(Vec<String>),
118}
119
120fn deserialize_string_or_vec<'de, D>(d: D) -> Result<Vec<String>, D::Error>
121where
122 D: Deserializer<'de>,
123{
124 Option::<StringOrVec>::deserialize(d).map(|opt| match opt {
125 None => Vec::new(),
126 Some(StringOrVec::One(s)) => vec![s],
127 Some(StringOrVec::Many(v)) => v,
128 })
129}
130
131fn default_config_version() -> u32 {
132 CONFIG_VERSION
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct DefaultsRaw {
138 pub snapshot_dir: Option<String>,
139 pub fallback_branch: Option<String>,
140 pub scan_paths: Option<Vec<String>>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub struct CacheRaw {
147 pub ttl_days: Option<u32>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154pub struct CacheSettings {
155 pub ttl_days: u32,
160}
161
162pub const DEFAULT_CACHE_TTL_DAYS: u32 = 42;
163
164impl Default for CacheSettings {
165 fn default() -> Self {
166 Self {
167 ttl_days: DEFAULT_CACHE_TTL_DAYS,
168 }
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct ProjectRaw {
175 pub name: String,
176 pub git_url: Option<String>,
177 pub kind: Option<String>,
178 pub family: Option<String>,
179 pub language: Option<String>,
180 pub tag_prefix: Option<String>,
181 pub public_modules: Option<Vec<String>>,
182 pub internal_modules: Option<Vec<String>>,
183 pub scan_paths: Option<Vec<String>>,
184 pub layout: Option<String>,
185 pub app_roots: Option<Vec<String>>,
186 pub include_apps: Option<Vec<String>>,
187 pub exclude_apps: Option<Vec<String>>,
188 pub excluded_subdirs: Option<Vec<String>>,
189 pub tag_pattern: Option<String>,
190 #[serde(alias = "oldest_tag")]
191 pub min_tag: Option<String>,
192 pub exclude_tag_markers: Option<Vec<String>>,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum ProjectKind {
198 External,
200 SelfRepo,
203}
204
205impl ProjectKind {
206 pub fn as_str(self) -> &'static str {
207 match self {
208 Self::External => "external",
209 Self::SelfRepo => "self",
210 }
211 }
212}
213
214impl FromStr for ProjectKind {
215 type Err = ConfigError;
216
217 fn from_str(s: &str) -> Result<Self, Self::Err> {
218 match s {
219 "external" => Ok(Self::External),
220 "self" => Ok(Self::SelfRepo),
221 other => Err(ConfigError::UnknownProjectKind(other.to_owned())),
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
230#[serde(rename_all = "snake_case")]
231pub enum ProjectFamily {
232 #[default]
234 Generic,
235 ErlangOtp,
237 Ra,
240 Osiris,
243 Khepri,
245 Rabbitmq,
249}
250
251impl ProjectFamily {
252 pub fn as_str(self) -> &'static str {
253 match self {
254 Self::Generic => "generic",
255 Self::ErlangOtp => "erlang_otp",
256 Self::Ra => "ra",
257 Self::Osiris => "osiris",
258 Self::Khepri => "khepri",
259 Self::Rabbitmq => "rabbitmq",
260 }
261 }
262}
263
264impl FromStr for ProjectFamily {
265 type Err = ConfigError;
266
267 fn from_str(s: &str) -> Result<Self, Self::Err> {
268 match s {
269 "generic" => Ok(Self::Generic),
270 "erlang_otp" => Ok(Self::ErlangOtp),
271 "ra" => Ok(Self::Ra),
272 "osiris" => Ok(Self::Osiris),
273 "khepri" => Ok(Self::Khepri),
274 "rabbitmq" => Ok(Self::Rabbitmq),
275 other => Err(ConfigError::UnknownProjectFamily(other.to_owned())),
276 }
277 }
278}
279
280impl ProjectFamily {
281 pub fn defaults(self) -> FamilyDefaults {
284 match self {
285 Self::Generic => FamilyDefaults::default(),
286 Self::ErlangOtp => FamilyDefaults::default(),
287 Self::Ra => FamilyDefaults {
288 wire_constants: vec![
289 WireConstantDecl::new("ra_log_segment", &["VERSION", "MAGIC"]),
290 WireConstantDecl::new("ra_log_wal", &["CURRENT_VERSION", "MAGIC"]),
291 WireConstantDecl::new("ra_log_snapshot", &["VERSION", "MAGIC"]),
292 WireConstantDecl::new("ra_snapshot", &["IDX_VERSION", "IDX_MAGIC"]),
293 WireConstantDecl::new("ra", &["RA_PROTO_VERSION"]),
294 ],
295 versioned_machines: vec![VersionedMachineDecl {
296 behaviour: "ra_machine".into(),
297 }],
298 versioned_machine_impls: Vec::new(),
299 test_helper_search_paths: Vec::new(),
300 },
301 Self::Osiris => FamilyDefaults {
302 wire_constants: vec![WireConstantDecl::new(
303 "osiris",
304 &[
305 "MAGIC",
306 "VERSION",
307 "IDX_VERSION",
308 "LOG_VERSION",
309 "IDX_HEADER",
310 "LOG_HEADER",
311 ],
312 )],
313 ..Default::default()
314 },
315 Self::Khepri => FamilyDefaults {
316 wire_constants: vec![WireConstantDecl::new(
317 "khepri_node",
318 &["INIT_DATA_VERSION", "INIT_CHILD_LIST_VERSION"],
319 )],
320 ..Default::default()
321 },
322 Self::Rabbitmq => FamilyDefaults {
323 wire_constants: Vec::new(),
324 versioned_machines: Vec::new(),
325 versioned_machine_impls: vec![
326 VersionedMachineImplDecl {
327 module: "rabbit_fifo".into(),
328 version_function: "version".into(),
329 allow_state_flag_gating: true,
330 },
331 VersionedMachineImplDecl {
332 module: "rabbit_stream_coordinator".into(),
333 version_function: "version".into(),
334 allow_state_flag_gating: false,
335 },
336 ],
337 test_helper_search_paths: rabbitmq_default_test_helper_search_paths(),
338 },
339 }
340 }
341}
342
343fn rabbitmq_default_test_helper_search_paths() -> Vec<String> {
351 vec![
352 "deps/*/test".to_owned(),
353 "deps/*/src".to_owned(),
354 "deps/rabbitmq_ct_helpers/src".to_owned(),
355 ]
356}
357
358#[derive(Debug, Clone, Default, PartialEq, Eq)]
359pub struct FamilyDefaults {
360 pub wire_constants: Vec<WireConstantDecl>,
361 pub versioned_machines: Vec<VersionedMachineDecl>,
362 pub versioned_machine_impls: Vec<VersionedMachineImplDecl>,
363 pub test_helper_search_paths: Vec<String>,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct WireConstantDecl {
370 pub module: String,
371 pub macros: Vec<String>,
372}
373
374impl WireConstantDecl {
375 pub fn new(module: &str, macros: &[&str]) -> Self {
376 Self {
377 module: module.to_owned(),
378 macros: macros.iter().map(|m| (*m).to_owned()).collect(),
379 }
380 }
381}
382
383#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct VersionedMachineDecl {
385 pub behaviour: String,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct VersionedMachineImplDecl {
390 pub module: String,
391 pub version_function: String,
392 pub allow_state_flag_gating: bool,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396#[serde(deny_unknown_fields)]
397pub struct SeriesRaw {
398 pub name: String,
399 pub pins: Vec<PinRaw>,
400 #[serde(default)]
404 pub untracked_projects: Vec<String>,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub target_repo_dir_path: Option<String>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub target_ref: Option<String>,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(untagged)]
420pub enum PinRaw {
421 Literal {
422 project: String,
423 tag: String,
424 },
425 Pattern {
426 project: String,
427 tag_pattern: String,
428 select: String,
429 },
430 SelfBranch {
431 project: String,
432 branch: String,
433 #[serde(default)]
434 repo_dir_path: Option<PathBuf>,
435 },
436 SelfSha {
437 project: String,
438 sha: String,
439 #[serde(default)]
440 repo_dir_path: Option<PathBuf>,
441 },
442}
443
444#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
445pub struct Defaults {
446 pub snapshot_dir: PathBuf,
447 pub fallback_branch: String,
448 pub scan_paths: Vec<String>,
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(rename_all = "snake_case")]
453pub enum Language {
454 Erlang,
455 Elixir,
456}
457
458impl Language {
459 pub const fn as_str(self) -> &'static str {
460 match self {
461 Self::Erlang => "erlang",
462 Self::Elixir => "elixir",
463 }
464 }
465}
466
467impl fmt::Display for Language {
468 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
469 f.write_str(self.as_str())
470 }
471}
472
473impl FromStr for Language {
474 type Err = ConfigError;
475
476 fn from_str(s: &str) -> Result<Self, Self::Err> {
477 match s {
478 "erlang" => Ok(Self::Erlang),
479 "elixir" => Ok(Self::Elixir),
480 other => Err(ConfigError::Name(NameError::PatternMismatch {
481 kind: "language",
482 value: other.to_owned(),
483 pattern: "erlang|elixir",
484 })),
485 }
486 }
487}
488
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
490#[serde(rename_all = "snake_case")]
491pub enum ProjectLayout {
492 SingleApp,
493 MultiApp,
494 ErlangOtp,
495}
496
497impl ProjectLayout {
498 pub fn as_str(self) -> &'static str {
499 match self {
500 Self::SingleApp => "single_app",
501 Self::MultiApp => "multi_app",
502 Self::ErlangOtp => "erlang_otp",
503 }
504 }
505
506 pub fn defaults(self) -> LayoutDefaults {
507 match self {
508 Self::SingleApp | Self::MultiApp => LayoutDefaults::default(),
509 Self::ErlangOtp => LayoutDefaults {
510 app_roots: ERLANG_OTP_APP_ROOTS
511 .iter()
512 .map(|s| (*s).to_owned())
513 .collect(),
514 include_apps: Vec::new(),
515 exclude_apps: ERLANG_OTP_EXCLUDE_APPS
516 .iter()
517 .map(|s| ApplicationName::new(*s).expect("static name"))
518 .collect(),
519 excluded_subdirs: ERLANG_OTP_EXCLUDED_SUBDIRS
520 .iter()
521 .map(|s| (*s).to_owned())
522 .collect(),
523 tag_pattern: Some(TagGlob::new(ERLANG_OTP_TAG_PATTERN).expect("static tag glob")),
524 min_tag: Some(TagName::new(ERLANG_OTP_MIN_TAG).expect("static tag")),
525 },
526 }
527 }
528}
529
530impl FromStr for ProjectLayout {
531 type Err = ConfigError;
532
533 fn from_str(s: &str) -> Result<Self, Self::Err> {
534 match s {
535 "single_app" => Ok(Self::SingleApp),
536 "multi_app" => Ok(Self::MultiApp),
537 "erlang_otp" => Ok(Self::ErlangOtp),
538 other => Err(ConfigError::UnknownProjectLayout(other.to_owned())),
539 }
540 }
541}
542
543#[derive(Debug, Clone, PartialEq, Eq, Default)]
544pub struct LayoutDefaults {
545 pub app_roots: Vec<String>,
546 pub include_apps: Vec<ApplicationName>,
547 pub exclude_apps: Vec<ApplicationName>,
548 pub excluded_subdirs: Vec<String>,
549 pub tag_pattern: Option<TagGlob>,
550 pub min_tag: Option<TagName>,
551}
552
553#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
554pub struct Project {
555 pub name: ProjectName,
556 pub git_url: Option<PathBuf>,
558 pub kind: ProjectKind,
559 pub family: ProjectFamily,
560 pub language: Language,
561 pub tag_prefix: String,
562 pub public_modules: Vec<String>,
563 pub internal_modules: Vec<String>,
564 pub layout: ProjectLayout,
565 pub scan_paths: Vec<String>,
566 pub app_roots: Vec<String>,
567 pub include_apps: Vec<ApplicationName>,
568 pub exclude_apps: Vec<ApplicationName>,
569 pub excluded_subdirs: Vec<String>,
570 pub tag_pattern: Option<TagGlob>,
571 pub min_tag: Option<TagName>,
572 pub exclude_tag_markers: Vec<String>,
573}
574
575impl Project {
576 pub fn is_prerelease_tag(&self, tag: &TagName) -> bool {
578 self.exclude_tag_markers
579 .iter()
580 .any(|marker| tag.as_str().contains(marker))
581 }
582
583 pub fn require_git_url(&self) -> Result<&Path, ConfigError> {
586 self.git_url
587 .as_deref()
588 .ok_or_else(|| ConfigError::SelfProjectHasNoGitUrl(self.name.to_string()))
589 }
590
591 pub fn is_self(&self) -> bool {
592 matches!(self.kind, ProjectKind::SelfRepo)
593 }
594
595 pub fn owns_path(&self, path: &Path) -> bool {
598 let candidate = path.to_string_lossy();
599 self.scan_paths
600 .iter()
601 .chain(self.app_roots.iter())
602 .any(|raw| path_under_glob_prefix(&candidate, raw))
603 }
604}
605
606fn path_under_glob_prefix(candidate: &str, raw: &str) -> bool {
607 let prefix = glob_directory_prefix(raw).trim_end_matches('/');
608 if prefix.is_empty() {
609 return false;
610 }
611 if !candidate.starts_with(prefix) {
612 return false;
613 }
614 let rest = &candidate[prefix.len()..];
615 rest.is_empty() || rest.starts_with('/')
616}
617
618fn glob_directory_prefix(pattern: &str) -> &str {
620 let metachars = ['*', '?', '[', '{'];
621 match pattern.find(metachars) {
622 Some(i) => {
623 let head = &pattern[..i];
624 match head.rfind('/') {
625 Some(slash) => &head[..=slash],
626 None => "",
627 }
628 }
629 None => pattern,
630 }
631}
632
633#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
634pub struct Series {
635 pub name: SeriesName,
636 pub pins: Vec<PinSpec>,
637 #[serde(default)]
639 pub untracked_projects: Vec<ProjectName>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub target_repo_dir_path: Option<PathBuf>,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub target_ref: Option<String>,
646}
647
648impl Series {
649 pub fn resolve_pins<M>(&self, store: &SnapshotStore<M>) -> Result<Vec<Pin>, ConfigError> {
651 pin::resolve_all(&self.pins, store)
652 }
653}
654
655#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
656pub struct Config {
657 pub config_path: PathBuf,
658 pub defaults: Defaults,
659 #[serde(default)]
660 pub cache: CacheSettings,
661 pub projects: Vec<Project>,
662 pub series: Vec<Series>,
663 pub suite_rules: Vec<ExtraRule>,
664 pub path_translations: PathTranslations,
665}
666
667impl Config {
668 pub fn load(path: &Path) -> Result<Self, ConfigError> {
669 if !path.exists() {
670 return Err(ConfigError::NotFound(path.to_path_buf()));
671 }
672 let text = fs::read_to_string(path)?;
673 let raw: ConfigFile = toml::from_str(&text)?;
674 Self::from_raw(path.to_path_buf(), raw)
675 }
676
677 pub fn from_raw(config_path: PathBuf, raw: ConfigFile) -> Result<Self, ConfigError> {
678 if raw.config_version != CONFIG_VERSION {
679 return Err(ConfigError::UnknownConfigVersion(raw.config_version));
680 }
681 let defaults = Defaults {
682 snapshot_dir: raw
683 .defaults
684 .snapshot_dir
685 .map(PathBuf::from)
686 .unwrap_or_else(|| PathBuf::from(DEFAULT_SNAPSHOT_DIR)),
687 fallback_branch: raw
688 .defaults
689 .fallback_branch
690 .unwrap_or_else(|| DEFAULT_FALLBACK_BRANCH.to_owned()),
691 scan_paths: raw
692 .defaults
693 .scan_paths
694 .unwrap_or_else(|| DEFAULT_SCAN_PATHS.iter().map(|s| (*s).to_owned()).collect()),
695 };
696 let cache = CacheSettings {
697 ttl_days: raw.cache.ttl_days.unwrap_or(DEFAULT_CACHE_TTL_DAYS),
698 };
699 let mut projects = Vec::with_capacity(raw.projects.len());
700 for p in raw.projects {
701 projects.push(parse_project(p, &defaults)?);
702 }
703 let self_projects: Vec<String> = projects
704 .iter()
705 .filter(|p| p.kind == ProjectKind::SelfRepo)
706 .map(|p| p.name.to_string())
707 .collect();
708 if self_projects.len() > 1 {
709 return Err(ConfigError::MultipleSelfProjects {
710 projects: self_projects,
711 });
712 }
713 let project_kinds: BTreeMap<&ProjectName, ProjectKind> =
714 projects.iter().map(|p| (&p.name, p.kind)).collect();
715 let mut series = Vec::with_capacity(raw.series.len());
716 for s in raw.series {
717 let mut pins = Vec::with_capacity(s.pins.len());
718 for pin in s.pins {
719 let spec = parse_pin(pin)?;
720 let Some(kind) = project_kinds.get(spec.project()).copied() else {
721 return Err(ConfigError::SeriesPinsUnknownProject {
722 series: s.name.clone(),
723 project: spec.project().to_string(),
724 });
725 };
726 if spec.is_self() && kind != ProjectKind::SelfRepo {
727 return Err(ConfigError::SelfPinReferencesExternalProject {
728 project: spec.project().to_string(),
729 });
730 }
731 pins.push(spec);
732 }
733 let mut untracked_projects = Vec::with_capacity(s.untracked_projects.len());
734 for name in s.untracked_projects {
735 let project = ProjectName::new(name).map_err(ConfigError::Name)?;
736 if !project_kinds.contains_key(&project) {
737 return Err(ConfigError::SeriesPinsUnknownProject {
738 series: s.name.clone(),
739 project: project.to_string(),
740 });
741 }
742 untracked_projects.push(project);
743 }
744 series.push(Series {
745 name: SeriesName::new(s.name).map_err(ConfigError::Name)?,
746 pins,
747 untracked_projects,
748 target_repo_dir_path: s.target_repo_dir_path.map(PathBuf::from),
749 target_ref: s.target_ref,
750 });
751 }
752 let mut suite_rules = Vec::with_capacity(raw.suite_rules.len());
753 for (idx, r) in raw.suite_rules.into_iter().enumerate() {
754 suite_rules.push(parse_suite_rule(idx, r)?);
755 }
756 let path_translations = PathTranslations::from_config_stanzas(raw.path_translations)?;
757 Ok(Self {
758 config_path,
759 defaults,
760 cache,
761 projects,
762 series,
763 suite_rules,
764 path_translations,
765 })
766 }
767
768 pub fn project(&self, name: &ProjectName) -> Result<&Project, ConfigError> {
769 self.projects
770 .iter()
771 .find(|p| &p.name == name)
772 .ok_or_else(|| ConfigError::UnknownProject(name.to_string()))
773 }
774
775 pub fn series_by_name(&self, name: &SeriesName) -> Result<&Series, ConfigError> {
776 self.series
777 .iter()
778 .find(|s| &s.name == name)
779 .ok_or_else(|| ConfigError::UnknownSeries(name.to_string()))
780 }
781
782 pub fn projects_missing_from_series(&self, series: &Series) -> Vec<ProjectName> {
784 let pinned: BTreeSet<&ProjectName> = series.pins.iter().map(|p| p.project()).collect();
785 let mut missing: Vec<ProjectName> = self
786 .projects
787 .iter()
788 .map(|p| &p.name)
789 .filter(|n| !pinned.contains(*n))
790 .cloned()
791 .collect();
792 missing.sort();
793 missing
794 }
795
796 pub fn series_by_name_with_coverage_check(
799 &self,
800 name: &SeriesName,
801 ) -> Result<&Series, ConfigError> {
802 let series = self.series_by_name(name)?;
803 let missing = self.projects_missing_from_series(series);
804 if !missing.is_empty() {
805 let names: Vec<&str> = missing.iter().map(ProjectName::as_str).collect();
806 tracing::warn!(
807 series = %name,
808 missing_projects = ?names,
809 "series {} pins {} project(s); these configured projects have no pin: {}. Was this intentional?",
810 name, series.pins.len(), names.join(", ")
811 );
812 }
813 Ok(series)
814 }
815
816 pub fn snapshot_dir(&self) -> PathBuf {
817 let dir = &self.defaults.snapshot_dir;
818 if dir.is_absolute() {
819 dir.clone()
820 } else {
821 self.config_path
822 .parent()
823 .map(|p| p.join(dir))
824 .unwrap_or_else(|| dir.clone())
825 }
826 }
827}
828
829pub fn parse_suite_rules_toml(text: &str) -> Result<Vec<ExtraRule>, ConfigError> {
833 #[derive(serde::Deserialize)]
834 #[serde(deny_unknown_fields)]
835 struct RulesDoc {
836 #[serde(default)]
837 suite_rule: Vec<SuiteRuleRaw>,
838 }
839 let doc: RulesDoc = toml::from_str(text)?;
840 doc.suite_rule
841 .into_iter()
842 .enumerate()
843 .map(|(idx, raw)| parse_suite_rule(idx, raw))
844 .collect()
845}
846
847fn parse_suite_rule(idx: usize, raw: SuiteRuleRaw) -> Result<ExtraRule, ConfigError> {
848 let pattern = raw.when_modified_path_matches;
849 let compiled = Regex::new(&pattern).map_err(|e| ConfigError::SuiteRuleRegex {
850 rule_index: idx,
851 detail: e.to_string(),
852 })?;
853 let path_capture_names: Vec<String> = compiled
854 .capture_names()
855 .flatten()
856 .map(|s| s.to_owned())
857 .collect();
858 let line_match = match raw.when_modified_line_matches {
859 Some(line_pattern) => {
860 let line_compiled =
861 Regex::new(&line_pattern).map_err(|e| ConfigError::SuiteRuleRegex {
862 rule_index: idx,
863 detail: e.to_string(),
864 })?;
865 let line_captures: Vec<String> = line_compiled
866 .capture_names()
867 .flatten()
868 .map(|s| s.to_owned())
869 .collect();
870 Some(LineMatch {
871 pattern: line_pattern,
872 captures: line_captures,
873 })
874 }
875 None => None,
876 };
877 let mut allowed_captures: Vec<String> = path_capture_names.clone();
878 if let Some(lm) = &line_match {
879 allowed_captures.extend(lm.captures.iter().cloned());
880 }
881 let mut templates: Vec<String> = Vec::new();
882 templates.extend(raw.include_suite);
883 templates.extend(raw.also);
884 for t in &templates {
885 if let Err(e) = validate_template_placeholders(t, &allowed_captures) {
886 return Err(ConfigError::SuiteRuleUnknownPlaceholder {
887 rule_index: idx,
888 placeholder: e.placeholder,
889 });
890 }
891 }
892 if raw.include_suite_for_dep_modules && !allowed_captures.iter().any(|c| c == "dep") {
893 return Err(ConfigError::SuiteRuleMissingDepCapture { rule_index: idx });
894 }
895 let name = raw.name.unwrap_or_else(|| format!("suite_rule_{idx}"));
896 Ok(ExtraRule {
897 name,
898 trigger: ExtraRuleTrigger::PathRegex {
899 pattern,
900 captures: path_capture_names,
901 },
902 include_suites: Vec::new(),
903 include_suite_templates: templates,
904 line_match,
905 include_suite_for_dep_modules: raw.include_suite_for_dep_modules,
906 })
907}
908
909fn parse_project(p: ProjectRaw, defaults: &Defaults) -> Result<Project, ConfigError> {
910 let layout = p
911 .layout
912 .as_deref()
913 .map(ProjectLayout::from_str)
914 .transpose()?
915 .unwrap_or(ProjectLayout::SingleApp);
916 let language = p
917 .language
918 .as_deref()
919 .map(Language::from_str)
920 .transpose()?
921 .unwrap_or(Language::Erlang);
922 let layout_defaults = layout.defaults();
923 let scan_paths = p.scan_paths.unwrap_or_else(|| match layout {
924 ProjectLayout::SingleApp => defaults.scan_paths.clone(),
925 ProjectLayout::MultiApp | ProjectLayout::ErlangOtp => Vec::new(),
926 });
927 let app_roots = p
928 .app_roots
929 .unwrap_or_else(|| layout_defaults.app_roots.clone());
930 let include_apps = match p.include_apps {
931 Some(v) => parse_app_names(v)?,
932 None => layout_defaults.include_apps.clone(),
933 };
934 let exclude_apps = match p.exclude_apps {
935 Some(v) => parse_app_names(v)?,
936 None => layout_defaults.exclude_apps.clone(),
937 };
938 let excluded_subdirs = p
939 .excluded_subdirs
940 .unwrap_or_else(|| layout_defaults.excluded_subdirs.clone());
941 let tag_pattern = match p.tag_pattern {
942 Some(s) => Some(TagGlob::new(s).map_err(ConfigError::Name)?),
943 None => layout_defaults.tag_pattern.clone(),
944 };
945 let min_tag = match p.min_tag {
946 Some(s) => Some(TagName::new(s).map_err(ConfigError::Name)?),
947 None => layout_defaults.min_tag.clone(),
948 };
949 let exclude_tag_markers = p.exclude_tag_markers.unwrap_or_else(|| {
950 DEFAULT_EXCLUDE_TAG_MARKERS
951 .iter()
952 .map(|s| (*s).to_owned())
953 .collect()
954 });
955 let name = ProjectName::new(p.name).map_err(ConfigError::Name)?;
956 if matches!(layout, ProjectLayout::MultiApp | ProjectLayout::ErlangOtp) && app_roots.is_empty()
957 {
958 return Err(ConfigError::LayoutWithoutAppRoots {
959 project: name.to_string(),
960 layout: layout.as_str().to_owned(),
961 });
962 }
963 let kind = p
964 .kind
965 .as_deref()
966 .map(ProjectKind::from_str)
967 .transpose()?
968 .unwrap_or(ProjectKind::External);
969 let family = p
970 .family
971 .as_deref()
972 .map(ProjectFamily::from_str)
973 .transpose()?
974 .unwrap_or_default();
975 let git_url = match (kind, p.git_url) {
976 (ProjectKind::External, Some(g)) => Some(PathBuf::from(g)),
977 (ProjectKind::External, None) => {
978 return Err(ConfigError::ExternalProjectMissingGitUrl(name.to_string()));
979 }
980 (ProjectKind::SelfRepo, None) => None,
981 (ProjectKind::SelfRepo, Some(_)) => {
982 return Err(ConfigError::SelfProjectHasGitUrl(name.to_string()));
983 }
984 };
985 Ok(Project {
986 name,
987 git_url,
988 kind,
989 family,
990 language,
991 tag_prefix: p.tag_prefix.unwrap_or_else(|| String::from("v")),
992 public_modules: p.public_modules.unwrap_or_default(),
993 internal_modules: p.internal_modules.unwrap_or_default(),
994 layout,
995 scan_paths,
996 app_roots,
997 include_apps,
998 exclude_apps,
999 excluded_subdirs,
1000 tag_pattern,
1001 min_tag,
1002 exclude_tag_markers,
1003 })
1004}
1005
1006fn parse_app_names(raw: Vec<String>) -> Result<Vec<ApplicationName>, ConfigError> {
1007 raw.into_iter()
1008 .map(|s| ApplicationName::new(s).map_err(ConfigError::Name))
1009 .collect()
1010}
1011
1012fn parse_pin(pin: PinRaw) -> Result<PinSpec, ConfigError> {
1013 match pin {
1014 PinRaw::SelfBranch {
1015 project,
1016 branch,
1017 repo_dir_path,
1018 } => {
1019 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1020 let git_ref = GitRef::new(branch).map_err(ConfigError::Name)?;
1021 Ok(PinSpec::SelfRef {
1022 project,
1023 git_ref,
1024 repo_dir_path,
1025 })
1026 }
1027 PinRaw::SelfSha {
1028 project,
1029 sha,
1030 repo_dir_path,
1031 } => {
1032 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1033 let git_ref = GitRef::new(sha).map_err(ConfigError::Name)?;
1034 Ok(PinSpec::SelfRef {
1035 project,
1036 git_ref,
1037 repo_dir_path,
1038 })
1039 }
1040 PinRaw::Literal { project, tag } => {
1041 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1042 let tag = TagName::new(tag).map_err(ConfigError::Name)?;
1043 Ok(PinSpec::literal(project, tag))
1044 }
1045 PinRaw::Pattern {
1046 project,
1047 tag_pattern,
1048 select,
1049 } => {
1050 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1051 let pattern = TagGlob::new(tag_pattern).map_err(ConfigError::Name)?;
1052 let select = match select.as_str() {
1053 "latest" => PinSelect::Latest,
1054 "oldest" => PinSelect::Oldest,
1055 other => return Err(ConfigError::PinUnknownSelect(other.to_owned())),
1056 };
1057 Ok(PinSpec::pattern(project, pattern, select))
1058 }
1059 }
1060}