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 sibling_drift_vocabulary: Vec::new(),
301 },
302 Self::Osiris => FamilyDefaults {
303 wire_constants: vec![WireConstantDecl::new(
304 "osiris",
305 &[
306 "MAGIC",
307 "VERSION",
308 "IDX_VERSION",
309 "LOG_VERSION",
310 "IDX_HEADER",
311 "LOG_HEADER",
312 ],
313 )],
314 ..Default::default()
315 },
316 Self::Khepri => FamilyDefaults {
317 wire_constants: vec![WireConstantDecl::new(
318 "khepri_node",
319 &["INIT_DATA_VERSION", "INIT_CHILD_LIST_VERSION"],
320 )],
321 ..Default::default()
322 },
323 Self::Rabbitmq => FamilyDefaults {
324 wire_constants: Vec::new(),
325 versioned_machines: Vec::new(),
326 versioned_machine_impls: vec![
327 VersionedMachineImplDecl {
328 module: "rabbit_fifo".into(),
329 version_function: "version".into(),
330 allow_state_flag_gating: true,
331 },
332 VersionedMachineImplDecl {
333 module: "rabbit_stream_coordinator".into(),
334 version_function: "version".into(),
335 allow_state_flag_gating: false,
336 },
337 ],
338 test_helper_search_paths: rabbitmq_default_test_helper_search_paths(),
339 sibling_drift_vocabulary: rabbitmq_default_sibling_drift_vocabulary(),
340 },
341 }
342 }
343}
344
345fn rabbitmq_default_test_helper_search_paths() -> Vec<String> {
353 vec![
354 "deps/*/test".to_owned(),
355 "deps/*/src".to_owned(),
356 "deps/rabbitmq_ct_helpers/src".to_owned(),
357 ]
358}
359
360fn rabbitmq_default_sibling_drift_vocabulary() -> Vec<String> {
368 [
369 "flake",
370 "crash",
371 "find_crashes",
372 "ignored_crashes",
373 "await",
374 "wait_for",
375 "assertMatch",
376 "_SUITE",
377 "deflake",
378 "race",
379 "retry",
380 "wait_until",
381 "eventually",
382 "polling",
383 "flaky",
384 "intermittent",
385 "mixed version",
386 "fix a test",
387 "fix test",
388 "test fix",
389 "fragile",
390 "expected exception",
391 ]
392 .iter()
393 .map(|s| (*s).to_owned())
394 .collect()
395}
396
397#[derive(Debug, Clone, Default, PartialEq, Eq)]
398pub struct FamilyDefaults {
399 pub wire_constants: Vec<WireConstantDecl>,
400 pub versioned_machines: Vec<VersionedMachineDecl>,
401 pub versioned_machine_impls: Vec<VersionedMachineImplDecl>,
402 pub test_helper_search_paths: Vec<String>,
405 pub sibling_drift_vocabulary: Vec<String>,
408}
409
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct WireConstantDecl {
412 pub module: String,
413 pub macros: Vec<String>,
414}
415
416impl WireConstantDecl {
417 pub fn new(module: &str, macros: &[&str]) -> Self {
418 Self {
419 module: module.to_owned(),
420 macros: macros.iter().map(|m| (*m).to_owned()).collect(),
421 }
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub struct VersionedMachineDecl {
427 pub behaviour: String,
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct VersionedMachineImplDecl {
432 pub module: String,
433 pub version_function: String,
434 pub allow_state_flag_gating: bool,
435}
436
437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
438#[serde(deny_unknown_fields)]
439pub struct SeriesRaw {
440 pub name: String,
441 pub pins: Vec<PinRaw>,
442 #[serde(default)]
446 pub untracked_projects: Vec<String>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub target_repo_dir_path: Option<String>,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub target_ref: Option<String>,
457}
458
459#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
460#[serde(untagged)]
463pub enum PinRaw {
464 Literal {
465 project: String,
466 tag: String,
467 },
468 Pattern {
469 project: String,
470 tag_pattern: String,
471 select: String,
472 },
473 SelfBranch {
474 project: String,
475 branch: String,
476 #[serde(default)]
477 repo_dir_path: Option<PathBuf>,
478 },
479 SelfSha {
480 project: String,
481 sha: String,
482 #[serde(default)]
483 repo_dir_path: Option<PathBuf>,
484 },
485}
486
487#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
488pub struct Defaults {
489 pub snapshot_dir: PathBuf,
490 pub fallback_branch: String,
491 pub scan_paths: Vec<String>,
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
495#[serde(rename_all = "snake_case")]
496pub enum Language {
497 Erlang,
498 Elixir,
499}
500
501impl Language {
502 pub const fn as_str(self) -> &'static str {
503 match self {
504 Self::Erlang => "erlang",
505 Self::Elixir => "elixir",
506 }
507 }
508}
509
510impl fmt::Display for Language {
511 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
512 f.write_str(self.as_str())
513 }
514}
515
516impl FromStr for Language {
517 type Err = ConfigError;
518
519 fn from_str(s: &str) -> Result<Self, Self::Err> {
520 match s {
521 "erlang" => Ok(Self::Erlang),
522 "elixir" => Ok(Self::Elixir),
523 other => Err(ConfigError::Name(NameError::PatternMismatch {
524 kind: "language",
525 value: other.to_owned(),
526 pattern: "erlang|elixir",
527 })),
528 }
529 }
530}
531
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
533#[serde(rename_all = "snake_case")]
534pub enum ProjectLayout {
535 SingleApp,
536 MultiApp,
537 ErlangOtp,
538}
539
540impl ProjectLayout {
541 pub fn as_str(self) -> &'static str {
542 match self {
543 Self::SingleApp => "single_app",
544 Self::MultiApp => "multi_app",
545 Self::ErlangOtp => "erlang_otp",
546 }
547 }
548
549 pub fn defaults(self) -> LayoutDefaults {
550 match self {
551 Self::SingleApp | Self::MultiApp => LayoutDefaults::default(),
552 Self::ErlangOtp => LayoutDefaults {
553 app_roots: ERLANG_OTP_APP_ROOTS
554 .iter()
555 .map(|s| (*s).to_owned())
556 .collect(),
557 include_apps: Vec::new(),
558 exclude_apps: ERLANG_OTP_EXCLUDE_APPS
559 .iter()
560 .map(|s| ApplicationName::new(*s).expect("static name"))
561 .collect(),
562 excluded_subdirs: ERLANG_OTP_EXCLUDED_SUBDIRS
563 .iter()
564 .map(|s| (*s).to_owned())
565 .collect(),
566 tag_pattern: Some(TagGlob::new(ERLANG_OTP_TAG_PATTERN).expect("static tag glob")),
567 min_tag: Some(TagName::new(ERLANG_OTP_MIN_TAG).expect("static tag")),
568 },
569 }
570 }
571}
572
573impl FromStr for ProjectLayout {
574 type Err = ConfigError;
575
576 fn from_str(s: &str) -> Result<Self, Self::Err> {
577 match s {
578 "single_app" => Ok(Self::SingleApp),
579 "multi_app" => Ok(Self::MultiApp),
580 "erlang_otp" => Ok(Self::ErlangOtp),
581 other => Err(ConfigError::UnknownProjectLayout(other.to_owned())),
582 }
583 }
584}
585
586#[derive(Debug, Clone, PartialEq, Eq, Default)]
587pub struct LayoutDefaults {
588 pub app_roots: Vec<String>,
589 pub include_apps: Vec<ApplicationName>,
590 pub exclude_apps: Vec<ApplicationName>,
591 pub excluded_subdirs: Vec<String>,
592 pub tag_pattern: Option<TagGlob>,
593 pub min_tag: Option<TagName>,
594}
595
596#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
597pub struct Project {
598 pub name: ProjectName,
599 pub git_url: Option<PathBuf>,
601 pub kind: ProjectKind,
602 pub family: ProjectFamily,
603 pub language: Language,
604 pub tag_prefix: String,
605 pub public_modules: Vec<String>,
606 pub internal_modules: Vec<String>,
607 pub layout: ProjectLayout,
608 pub scan_paths: Vec<String>,
609 pub app_roots: Vec<String>,
610 pub include_apps: Vec<ApplicationName>,
611 pub exclude_apps: Vec<ApplicationName>,
612 pub excluded_subdirs: Vec<String>,
613 pub tag_pattern: Option<TagGlob>,
614 pub min_tag: Option<TagName>,
615 pub exclude_tag_markers: Vec<String>,
616}
617
618impl Project {
619 pub fn is_prerelease_tag(&self, tag: &TagName) -> bool {
621 self.exclude_tag_markers
622 .iter()
623 .any(|marker| tag.as_str().contains(marker))
624 }
625
626 pub fn require_git_url(&self) -> Result<&Path, ConfigError> {
629 self.git_url
630 .as_deref()
631 .ok_or_else(|| ConfigError::SelfProjectHasNoGitUrl(self.name.to_string()))
632 }
633
634 pub fn is_self(&self) -> bool {
635 matches!(self.kind, ProjectKind::SelfRepo)
636 }
637
638 pub fn owns_path(&self, path: &Path) -> bool {
641 let candidate = path.to_string_lossy();
642 self.scan_paths
643 .iter()
644 .chain(self.app_roots.iter())
645 .any(|raw| path_under_glob_prefix(&candidate, raw))
646 }
647}
648
649fn path_under_glob_prefix(candidate: &str, raw: &str) -> bool {
650 let prefix = glob_directory_prefix(raw).trim_end_matches('/');
651 if prefix.is_empty() {
652 return false;
653 }
654 if !candidate.starts_with(prefix) {
655 return false;
656 }
657 let rest = &candidate[prefix.len()..];
658 rest.is_empty() || rest.starts_with('/')
659}
660
661fn glob_directory_prefix(pattern: &str) -> &str {
663 let metachars = ['*', '?', '[', '{'];
664 match pattern.find(metachars) {
665 Some(i) => {
666 let head = &pattern[..i];
667 match head.rfind('/') {
668 Some(slash) => &head[..=slash],
669 None => "",
670 }
671 }
672 None => pattern,
673 }
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
677pub struct Series {
678 pub name: SeriesName,
679 pub pins: Vec<PinSpec>,
680 #[serde(default)]
682 pub untracked_projects: Vec<ProjectName>,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub target_repo_dir_path: Option<PathBuf>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub target_ref: Option<String>,
689}
690
691impl Series {
692 pub fn resolve_pins<M>(&self, store: &SnapshotStore<M>) -> Result<Vec<Pin>, ConfigError> {
694 pin::resolve_all(&self.pins, store)
695 }
696}
697
698#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
699pub struct Config {
700 pub config_path: PathBuf,
701 pub defaults: Defaults,
702 #[serde(default)]
703 pub cache: CacheSettings,
704 pub projects: Vec<Project>,
705 pub series: Vec<Series>,
706 pub suite_rules: Vec<ExtraRule>,
707 pub path_translations: PathTranslations,
708}
709
710impl Config {
711 pub fn load(path: &Path) -> Result<Self, ConfigError> {
712 if !path.exists() {
713 return Err(ConfigError::NotFound(path.to_path_buf()));
714 }
715 let text = fs::read_to_string(path)?;
716 let raw: ConfigFile = toml::from_str(&text)?;
717 Self::from_raw(path.to_path_buf(), raw)
718 }
719
720 pub fn from_raw(config_path: PathBuf, raw: ConfigFile) -> Result<Self, ConfigError> {
721 if raw.config_version != CONFIG_VERSION {
722 return Err(ConfigError::UnknownConfigVersion(raw.config_version));
723 }
724 let defaults = Defaults {
725 snapshot_dir: raw
726 .defaults
727 .snapshot_dir
728 .map(PathBuf::from)
729 .unwrap_or_else(|| PathBuf::from(DEFAULT_SNAPSHOT_DIR)),
730 fallback_branch: raw
731 .defaults
732 .fallback_branch
733 .unwrap_or_else(|| DEFAULT_FALLBACK_BRANCH.to_owned()),
734 scan_paths: raw
735 .defaults
736 .scan_paths
737 .unwrap_or_else(|| DEFAULT_SCAN_PATHS.iter().map(|s| (*s).to_owned()).collect()),
738 };
739 let cache = CacheSettings {
740 ttl_days: raw.cache.ttl_days.unwrap_or(DEFAULT_CACHE_TTL_DAYS),
741 };
742 let mut projects = Vec::with_capacity(raw.projects.len());
743 for p in raw.projects {
744 projects.push(parse_project(p, &defaults)?);
745 }
746 let self_projects: Vec<String> = projects
747 .iter()
748 .filter(|p| p.kind == ProjectKind::SelfRepo)
749 .map(|p| p.name.to_string())
750 .collect();
751 if self_projects.len() > 1 {
752 return Err(ConfigError::MultipleSelfProjects {
753 projects: self_projects,
754 });
755 }
756 let project_kinds: BTreeMap<&ProjectName, ProjectKind> =
757 projects.iter().map(|p| (&p.name, p.kind)).collect();
758 let mut series = Vec::with_capacity(raw.series.len());
759 for s in raw.series {
760 let mut pins = Vec::with_capacity(s.pins.len());
761 for pin in s.pins {
762 let spec = parse_pin(pin)?;
763 let Some(kind) = project_kinds.get(spec.project()).copied() else {
764 return Err(ConfigError::SeriesPinsUnknownProject {
765 series: s.name.clone(),
766 project: spec.project().to_string(),
767 });
768 };
769 if spec.is_self() && kind != ProjectKind::SelfRepo {
770 return Err(ConfigError::SelfPinReferencesExternalProject {
771 project: spec.project().to_string(),
772 });
773 }
774 pins.push(spec);
775 }
776 let mut untracked_projects = Vec::with_capacity(s.untracked_projects.len());
777 for name in s.untracked_projects {
778 let project = ProjectName::new(name).map_err(ConfigError::Name)?;
779 if !project_kinds.contains_key(&project) {
780 return Err(ConfigError::SeriesPinsUnknownProject {
781 series: s.name.clone(),
782 project: project.to_string(),
783 });
784 }
785 untracked_projects.push(project);
786 }
787 series.push(Series {
788 name: SeriesName::new(s.name).map_err(ConfigError::Name)?,
789 pins,
790 untracked_projects,
791 target_repo_dir_path: s.target_repo_dir_path.map(PathBuf::from),
792 target_ref: s.target_ref,
793 });
794 }
795 let mut suite_rules = Vec::with_capacity(raw.suite_rules.len());
796 for (idx, r) in raw.suite_rules.into_iter().enumerate() {
797 suite_rules.push(parse_suite_rule(idx, r)?);
798 }
799 let path_translations = PathTranslations::from_config_stanzas(raw.path_translations)?;
800 Ok(Self {
801 config_path,
802 defaults,
803 cache,
804 projects,
805 series,
806 suite_rules,
807 path_translations,
808 })
809 }
810
811 pub fn project(&self, name: &ProjectName) -> Result<&Project, ConfigError> {
812 self.projects
813 .iter()
814 .find(|p| &p.name == name)
815 .ok_or_else(|| ConfigError::UnknownProject(name.to_string()))
816 }
817
818 pub fn series_by_name(&self, name: &SeriesName) -> Result<&Series, ConfigError> {
819 self.series
820 .iter()
821 .find(|s| &s.name == name)
822 .ok_or_else(|| ConfigError::UnknownSeries(name.to_string()))
823 }
824
825 pub fn projects_missing_from_series(&self, series: &Series) -> Vec<ProjectName> {
827 let pinned: BTreeSet<&ProjectName> = series.pins.iter().map(|p| p.project()).collect();
828 let mut missing: Vec<ProjectName> = self
829 .projects
830 .iter()
831 .map(|p| &p.name)
832 .filter(|n| !pinned.contains(*n))
833 .cloned()
834 .collect();
835 missing.sort();
836 missing
837 }
838
839 pub fn series_by_name_with_coverage_check(
842 &self,
843 name: &SeriesName,
844 ) -> Result<&Series, ConfigError> {
845 let series = self.series_by_name(name)?;
846 let missing = self.projects_missing_from_series(series);
847 if !missing.is_empty() {
848 let names: Vec<&str> = missing.iter().map(ProjectName::as_str).collect();
849 tracing::warn!(
850 series = %name,
851 missing_projects = ?names,
852 "series {} pins {} project(s); these configured projects have no pin: {}. Was this intentional?",
853 name, series.pins.len(), names.join(", ")
854 );
855 }
856 Ok(series)
857 }
858
859 pub fn snapshot_dir(&self) -> PathBuf {
860 let dir = &self.defaults.snapshot_dir;
861 if dir.is_absolute() {
862 dir.clone()
863 } else {
864 self.config_path
865 .parent()
866 .map(|p| p.join(dir))
867 .unwrap_or_else(|| dir.clone())
868 }
869 }
870}
871
872pub fn parse_suite_rules_toml(text: &str) -> Result<Vec<ExtraRule>, ConfigError> {
876 #[derive(serde::Deserialize)]
877 #[serde(deny_unknown_fields)]
878 struct RulesDoc {
879 #[serde(default)]
880 suite_rule: Vec<SuiteRuleRaw>,
881 }
882 let doc: RulesDoc = toml::from_str(text)?;
883 doc.suite_rule
884 .into_iter()
885 .enumerate()
886 .map(|(idx, raw)| parse_suite_rule(idx, raw))
887 .collect()
888}
889
890fn parse_suite_rule(idx: usize, raw: SuiteRuleRaw) -> Result<ExtraRule, ConfigError> {
891 let pattern = raw.when_modified_path_matches;
892 let compiled = Regex::new(&pattern).map_err(|e| ConfigError::SuiteRuleRegex {
893 rule_index: idx,
894 detail: e.to_string(),
895 })?;
896 let path_capture_names: Vec<String> = compiled
897 .capture_names()
898 .flatten()
899 .map(|s| s.to_owned())
900 .collect();
901 let line_match = match raw.when_modified_line_matches {
902 Some(line_pattern) => {
903 let line_compiled =
904 Regex::new(&line_pattern).map_err(|e| ConfigError::SuiteRuleRegex {
905 rule_index: idx,
906 detail: e.to_string(),
907 })?;
908 let line_captures: Vec<String> = line_compiled
909 .capture_names()
910 .flatten()
911 .map(|s| s.to_owned())
912 .collect();
913 Some(LineMatch {
914 pattern: line_pattern,
915 captures: line_captures,
916 })
917 }
918 None => None,
919 };
920 let mut allowed_captures: Vec<String> = path_capture_names.clone();
921 if let Some(lm) = &line_match {
922 allowed_captures.extend(lm.captures.iter().cloned());
923 }
924 let mut templates: Vec<String> = Vec::new();
925 templates.extend(raw.include_suite);
926 templates.extend(raw.also);
927 for t in &templates {
928 if let Err(e) = validate_template_placeholders(t, &allowed_captures) {
929 return Err(ConfigError::SuiteRuleUnknownPlaceholder {
930 rule_index: idx,
931 placeholder: e.placeholder,
932 });
933 }
934 }
935 if raw.include_suite_for_dep_modules && !allowed_captures.iter().any(|c| c == "dep") {
936 return Err(ConfigError::SuiteRuleMissingDepCapture { rule_index: idx });
937 }
938 let name = raw.name.unwrap_or_else(|| format!("suite_rule_{idx}"));
939 Ok(ExtraRule {
940 name,
941 trigger: ExtraRuleTrigger::PathRegex {
942 pattern,
943 captures: path_capture_names,
944 },
945 include_suites: Vec::new(),
946 include_suite_templates: templates,
947 line_match,
948 include_suite_for_dep_modules: raw.include_suite_for_dep_modules,
949 })
950}
951
952fn parse_project(p: ProjectRaw, defaults: &Defaults) -> Result<Project, ConfigError> {
953 let layout = p
954 .layout
955 .as_deref()
956 .map(ProjectLayout::from_str)
957 .transpose()?
958 .unwrap_or(ProjectLayout::SingleApp);
959 let language = p
960 .language
961 .as_deref()
962 .map(Language::from_str)
963 .transpose()?
964 .unwrap_or(Language::Erlang);
965 let layout_defaults = layout.defaults();
966 let scan_paths = p.scan_paths.unwrap_or_else(|| match layout {
967 ProjectLayout::SingleApp => defaults.scan_paths.clone(),
968 ProjectLayout::MultiApp | ProjectLayout::ErlangOtp => Vec::new(),
969 });
970 let app_roots = p
971 .app_roots
972 .unwrap_or_else(|| layout_defaults.app_roots.clone());
973 let include_apps = match p.include_apps {
974 Some(v) => parse_app_names(v)?,
975 None => layout_defaults.include_apps.clone(),
976 };
977 let exclude_apps = match p.exclude_apps {
978 Some(v) => parse_app_names(v)?,
979 None => layout_defaults.exclude_apps.clone(),
980 };
981 let excluded_subdirs = p
982 .excluded_subdirs
983 .unwrap_or_else(|| layout_defaults.excluded_subdirs.clone());
984 let tag_pattern = match p.tag_pattern {
985 Some(s) => Some(TagGlob::new(s).map_err(ConfigError::Name)?),
986 None => layout_defaults.tag_pattern.clone(),
987 };
988 let min_tag = match p.min_tag {
989 Some(s) => Some(TagName::new(s).map_err(ConfigError::Name)?),
990 None => layout_defaults.min_tag.clone(),
991 };
992 let exclude_tag_markers = p.exclude_tag_markers.unwrap_or_else(|| {
993 DEFAULT_EXCLUDE_TAG_MARKERS
994 .iter()
995 .map(|s| (*s).to_owned())
996 .collect()
997 });
998 let name = ProjectName::new(p.name).map_err(ConfigError::Name)?;
999 if matches!(layout, ProjectLayout::MultiApp | ProjectLayout::ErlangOtp) && app_roots.is_empty()
1000 {
1001 return Err(ConfigError::LayoutWithoutAppRoots {
1002 project: name.to_string(),
1003 layout: layout.as_str().to_owned(),
1004 });
1005 }
1006 let kind = p
1007 .kind
1008 .as_deref()
1009 .map(ProjectKind::from_str)
1010 .transpose()?
1011 .unwrap_or(ProjectKind::External);
1012 let family = p
1013 .family
1014 .as_deref()
1015 .map(ProjectFamily::from_str)
1016 .transpose()?
1017 .unwrap_or_default();
1018 let git_url = match (kind, p.git_url) {
1019 (ProjectKind::External, Some(g)) => Some(PathBuf::from(g)),
1020 (ProjectKind::External, None) => {
1021 return Err(ConfigError::ExternalProjectMissingGitUrl(name.to_string()));
1022 }
1023 (ProjectKind::SelfRepo, None) => None,
1024 (ProjectKind::SelfRepo, Some(_)) => {
1025 return Err(ConfigError::SelfProjectHasGitUrl(name.to_string()));
1026 }
1027 };
1028 Ok(Project {
1029 name,
1030 git_url,
1031 kind,
1032 family,
1033 language,
1034 tag_prefix: p.tag_prefix.unwrap_or_else(|| String::from("v")),
1035 public_modules: p.public_modules.unwrap_or_default(),
1036 internal_modules: p.internal_modules.unwrap_or_default(),
1037 layout,
1038 scan_paths,
1039 app_roots,
1040 include_apps,
1041 exclude_apps,
1042 excluded_subdirs,
1043 tag_pattern,
1044 min_tag,
1045 exclude_tag_markers,
1046 })
1047}
1048
1049fn parse_app_names(raw: Vec<String>) -> Result<Vec<ApplicationName>, ConfigError> {
1050 raw.into_iter()
1051 .map(|s| ApplicationName::new(s).map_err(ConfigError::Name))
1052 .collect()
1053}
1054
1055fn parse_pin(pin: PinRaw) -> Result<PinSpec, ConfigError> {
1056 match pin {
1057 PinRaw::SelfBranch {
1058 project,
1059 branch,
1060 repo_dir_path,
1061 } => {
1062 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1063 let git_ref = GitRef::new(branch).map_err(ConfigError::Name)?;
1064 Ok(PinSpec::SelfRef {
1065 project,
1066 git_ref,
1067 repo_dir_path,
1068 })
1069 }
1070 PinRaw::SelfSha {
1071 project,
1072 sha,
1073 repo_dir_path,
1074 } => {
1075 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1076 let git_ref = GitRef::new(sha).map_err(ConfigError::Name)?;
1077 Ok(PinSpec::SelfRef {
1078 project,
1079 git_ref,
1080 repo_dir_path,
1081 })
1082 }
1083 PinRaw::Literal { project, tag } => {
1084 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1085 let tag = TagName::new(tag).map_err(ConfigError::Name)?;
1086 Ok(PinSpec::literal(project, tag))
1087 }
1088 PinRaw::Pattern {
1089 project,
1090 tag_pattern,
1091 select,
1092 } => {
1093 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1094 let pattern = TagGlob::new(tag_pattern).map_err(ConfigError::Name)?;
1095 let select = match select.as_str() {
1096 "latest" => PinSelect::Latest,
1097 "oldest" => PinSelect::Oldest,
1098 other => return Err(ConfigError::PinUnknownSelect(other.to_owned())),
1099 };
1100 Ok(PinSpec::pattern(project, pattern, select))
1101 }
1102 }
1103}