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