1mod host;
8mod path_translation;
9
10pub use host::{
11 ERLANG_OTP_TAG_PREFIX, ErlangSupport, ErlangSupportRaw, Host, HostConfigError, HostPinsRaw,
12 HostRaw, MakefileVariableRaw, PinSource, PinSourceKind, ReleaseRootsRaw,
13};
14pub use path_translation::{
15 PathTranslation, PathTranslations, TranslationDirection, TranslationOrigin,
16};
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::str::FromStr;
22use std::sync::LazyLock;
23
24use regex::Regex;
25use serde::{Deserialize, Deserializer, Serialize};
26
27use crate::errors::{ConfigError, NameError};
28use crate::model::app_manifest::ManifestSource;
29use crate::model::names::{
30 ApplicationName, BehaviourName, FunctionName, GitRef, HostName, MacroName, ModuleName,
31 ProjectName, SeriesName, TagGlob, TagName, TypeName, vocabulary,
32};
33use crate::model::pin::{self, Pin, PinSelect, PinSpec};
34use crate::store::{SnapshotStore, StoreMode};
35use crate::suites::rules::validate_template_placeholders;
36use crate::suites::{ExtraRule, ExtraRuleTrigger, LineMatch};
37
38pub const CONFIG_VERSION: u32 = 1;
39
40const DEFAULT_SCAN_PATHS: &[&str] = &["src/**/*.erl", "src/**/*.ex", "include/**/*.hrl"];
41const DEFAULT_SNAPSHOT_DIR: &str = "snapshots";
42const DEFAULT_FALLBACK_BRANCH: &str = "main";
43
44const ERLANG_OTP_APP_ROOTS: &[&str] = &["lib/*", "erts/preloaded"];
45const ERLANG_OTP_EXCLUDE_APPS: &[&str] = &[
46 "odbc",
47 "snmp",
48 "ssh",
49 "tftp",
50 "ftp",
51 "wx",
52 "megaco",
53 "edoc",
54 "jinterface",
55 "diameter",
56];
57const ERLANG_OTP_EXCLUDED_SUBDIRS: &[&str] = &["doc", "example", "examples", "test"];
58const ERLANG_OTP_TAG_PATTERN: &str = "OTP-*";
59const ERLANG_OTP_MIN_TAG: &str = "OTP-26.0";
60
61const DEFAULT_EXCLUDE_TAG_MARKERS: &[&str] = &["-rc", "-alpha", "-beta", "-pre"];
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct ConfigFile {
68 #[serde(default = "default_config_version")]
69 pub config_version: u32,
70
71 #[serde(default)]
72 pub defaults: DefaultsRaw,
73
74 #[serde(default)]
75 pub cache: CacheRaw,
76
77 #[serde(default, rename = "project")]
78 pub projects: Vec<ProjectRaw>,
79
80 #[serde(default, rename = "series")]
81 pub series: Vec<SeriesRaw>,
82
83 #[serde(default, rename = "host")]
84 pub hosts: Vec<HostRaw>,
85
86 #[serde(default, rename = "suite_rule")]
87 pub suite_rules: Vec<SuiteRuleRaw>,
88
89 #[serde(default, rename = "path_translation")]
90 pub path_translations: Vec<PathTranslationRaw>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct PathTranslationRaw {
96 pub name: String,
97 #[serde(default = "default_translation_direction")]
98 pub direction: String,
99 pub source_prefix: String,
100 pub target_prefix: String,
101}
102
103fn default_translation_direction() -> String {
104 "source_to_target".to_owned()
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct SuiteRuleRaw {
113 pub name: Option<String>,
114 pub when_modified_path_matches: String,
115 #[serde(default)]
116 pub when_modified_line_matches: Option<String>,
117 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
118 pub include_suite: Vec<String>,
119 #[serde(default, deserialize_with = "deserialize_string_or_vec")]
120 pub also: Vec<String>,
121 #[serde(default)]
122 pub include_suite_for_dep_modules: bool,
123}
124
125#[derive(Deserialize)]
126#[serde(untagged)]
127enum StringOrVec {
128 One(String),
129 Many(Vec<String>),
130}
131
132fn deserialize_string_or_vec<'de, D>(d: D) -> Result<Vec<String>, D::Error>
133where
134 D: Deserializer<'de>,
135{
136 Option::<StringOrVec>::deserialize(d).map(|opt| match opt {
137 None => Vec::new(),
138 Some(StringOrVec::One(s)) => vec![s],
139 Some(StringOrVec::Many(v)) => v,
140 })
141}
142
143fn default_config_version() -> u32 {
144 CONFIG_VERSION
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct DefaultsRaw {
150 pub snapshot_dir: Option<String>,
151 pub fallback_branch: Option<String>,
152 pub scan_paths: Option<Vec<String>>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
157#[serde(deny_unknown_fields)]
158pub struct CacheRaw {
159 pub ttl_days: Option<u32>,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
166pub struct CacheSettings {
167 pub ttl_days: u32,
172}
173
174pub const DEFAULT_CACHE_TTL_DAYS: u32 = 42;
175
176impl Default for CacheSettings {
177 fn default() -> Self {
178 Self {
179 ttl_days: DEFAULT_CACHE_TTL_DAYS,
180 }
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct ProjectRaw {
187 pub name: String,
188 pub git_url: Option<String>,
189 pub kind: Option<String>,
190 pub family: Option<String>,
191 pub language: Option<String>,
192 pub tag_prefix: Option<String>,
193 pub public_modules: Option<Vec<String>>,
194 pub internal_modules: Option<Vec<String>>,
195 pub scan_paths: Option<Vec<String>>,
196 pub layout: Option<String>,
197 pub app_roots: Option<Vec<String>>,
198 pub include_apps: Option<Vec<String>>,
199 pub exclude_apps: Option<Vec<String>>,
200 pub excluded_subdirs: Option<Vec<String>>,
201 pub tag_pattern: Option<String>,
202 #[serde(alias = "oldest_tag")]
203 pub min_tag: Option<String>,
204 pub exclude_tag_markers: Option<Vec<String>>,
205 pub manifest: Option<String>,
206}
207
208vocabulary!(
209 pub enum ProjectKind: "project kind" {
210 External => "external",
212 SelfRepo => "self",
215 }
216);
217
218vocabulary!(
219 #[derive(Default)]
223 pub enum ProjectFamily: "project family" {
224 #[default]
226 Generic => "generic",
227 ErlangOtp => "erlang_otp",
229 Ra => "ra",
232 Osiris => "osiris",
235 Khepri => "khepri",
237 Rabbitmq => "rabbitmq",
241 Cowboy => "cowboy",
244 }
245);
246
247impl ProjectFamily {
248 pub fn defaults(self) -> FamilyDefaults {
251 match self {
252 Self::Generic | Self::ErlangOtp => FamilyDefaults::default(),
253 Self::Ra => RA_DEFAULTS.clone(),
254 Self::Osiris => OSIRIS_DEFAULTS.clone(),
255 Self::Khepri => KHEPRI_DEFAULTS.clone(),
256 Self::Rabbitmq => RABBITMQ_DEFAULTS.clone(),
257 Self::Cowboy => COWBOY_DEFAULTS.clone(),
258 }
259 }
260}
261
262const RA_WIRE_CONSTANTS: &[(&str, &[&str])] = &[
263 ("ra_log_segment", &["VERSION", "MAGIC"]),
264 ("ra_log_wal", &["CURRENT_VERSION", "MAGIC"]),
265 ("ra_log_snapshot", &["VERSION", "MAGIC"]),
266 ("ra_snapshot", &["IDX_VERSION", "IDX_MAGIC"]),
267 (
275 "ra",
276 &[
277 "RA_PROTO_VERSION",
278 "C_RA_LOG_WRITE_OPS",
279 "C_RA_LOG_WRITE_RESENDS",
280 "C_RA_LOG_READ_OPS",
281 "C_RA_LOG_READ_CACHE",
282 "C_RA_LOG_READ_MEM_TBL",
283 "C_RA_LOG_READ_CLOSED_MEM_TBL",
284 "C_RA_LOG_READ_SEGMENT",
285 "C_RA_LOG_FETCH_TERM",
286 "C_RA_LOG_SNAPSHOTS_WRITTEN",
287 "C_RA_LOG_SNAPSHOTS_INSTALLED",
288 "C_RA_LOG_SNAPSHOT_BYTES_WRITTEN",
289 "C_RA_LOG_OPEN_SEGMENTS",
290 "C_RA_LOG_CHECKPOINTS_WRITTEN",
291 "C_RA_LOG_CHECKPOINT_BYTES_WRITTEN",
292 "C_RA_LOG_CHECKPOINTS_PROMOTED",
293 "C_RA_LOG_COMPACTIONS_MINOR_COUNT",
294 "C_RA_LOG_COMPACTIONS_MAJOR_COUNT",
295 "C_RA_LOG_COMPACTIONS_SEGMENTS_WRITTEN",
296 "C_RA_LOG_COMPACTIONS_SEGMENTS_COMPACTED",
297 "C_RA_LOG_RESERVED",
298 ],
299 ),
300];
301const RA_VERSIONED_MACHINES: &[&str] = &["ra_machine"];
302const RA_DEP_BEHAVIOURS: &[&str] = &["ra_machine", "ra_snapshot"];
303
304const OSIRIS_WIRE_CONSTANTS: &[(&str, &[&str])] = &[(
305 "osiris",
306 &[
307 "MAGIC",
308 "VERSION",
309 "IDX_VERSION",
310 "LOG_VERSION",
311 "IDX_HEADER",
312 "LOG_HEADER",
313 ],
314)];
315
316const KHEPRI_WIRE_CONSTANTS: &[(&str, &[&str])] = &[(
317 "khepri_node",
318 &["INIT_DATA_VERSION", "INIT_CHILD_LIST_VERSION"],
319)];
320
321const RABBITMQ_VERSIONED_MACHINE_IMPLS: &[(&str, &str, bool)] = &[
322 ("rabbit_fifo", "version", true),
323 ("rabbit_stream_coordinator", "version", false),
324];
325
326fn build_wire_constants(rows: &[(&str, &[&str])]) -> Result<Vec<WireConstantDecl>, NameError> {
327 rows.iter()
328 .map(|(module, macros)| {
329 Ok(WireConstantDecl {
330 module: ModuleName::from_str(module)?,
331 macros: macros
332 .iter()
333 .map(|m| MacroName::from_str(m))
334 .collect::<Result<_, _>>()?,
335 })
336 })
337 .collect()
338}
339
340fn build_versioned_machines(behaviours: &[&str]) -> Result<Vec<VersionedMachineDecl>, NameError> {
341 behaviours
342 .iter()
343 .map(|b| {
344 Ok(VersionedMachineDecl {
345 behaviour: BehaviourName::from_str(b)?,
346 })
347 })
348 .collect()
349}
350
351fn build_versioned_machine_impls(
352 rows: &[(&str, &str, bool)],
353) -> Result<Vec<VersionedMachineImplDecl>, NameError> {
354 rows.iter()
355 .map(|(module, version_function, allow_state_flag_gating)| {
356 Ok(VersionedMachineImplDecl {
357 module: ModuleName::from_str(module)?,
358 version_function: FunctionName::from_str(version_function)?,
359 allow_state_flag_gating: *allow_state_flag_gating,
360 })
361 })
362 .collect()
363}
364
365const RA_OPTION_TYPES: &[(&str, &str)] =
366 &[("ra_system", "config"), ("ra_server", "ra_server_config")];
367
368const COWBOY_OPTION_TYPES: &[(&str, &str)] = &[
369 ("cowboy_http", "opts"),
370 ("cowboy_http2", "opts"),
371 ("cowboy_websocket", "opts"),
372];
373
374const RABBITMQ_SUITE_REGISTRATION_MARKER: &str = "PARALLEL_CT";
375
376fn build_option_types(rows: &[(&str, &str)]) -> Result<Vec<OptionTypeDecl>, NameError> {
377 rows.iter()
378 .map(|(module, type_name)| OptionTypeDecl::new(module, type_name))
379 .collect()
380}
381
382fn build_ra_defaults() -> Result<FamilyDefaults, NameError> {
383 Ok(FamilyDefaults {
384 wire_constants: build_wire_constants(RA_WIRE_CONSTANTS)?,
385 versioned_machines: build_versioned_machines(RA_VERSIONED_MACHINES)?,
386 versioned_machine_impls: Vec::new(),
387 test_helper_search_paths: Vec::new(),
388 dep_behaviours: RA_DEP_BEHAVIOURS.iter().map(|s| (*s).to_owned()).collect(),
389 option_types: build_option_types(RA_OPTION_TYPES)?,
390 suite_registration: None,
391 })
392}
393
394fn build_osiris_defaults() -> Result<FamilyDefaults, NameError> {
395 Ok(FamilyDefaults {
396 wire_constants: build_wire_constants(OSIRIS_WIRE_CONSTANTS)?,
397 ..Default::default()
398 })
399}
400
401fn build_khepri_defaults() -> Result<FamilyDefaults, NameError> {
402 Ok(FamilyDefaults {
403 wire_constants: build_wire_constants(KHEPRI_WIRE_CONSTANTS)?,
404 ..Default::default()
405 })
406}
407
408fn build_rabbitmq_defaults() -> Result<FamilyDefaults, NameError> {
409 Ok(FamilyDefaults {
410 wire_constants: Vec::new(),
411 versioned_machines: Vec::new(),
412 versioned_machine_impls: build_versioned_machine_impls(RABBITMQ_VERSIONED_MACHINE_IMPLS)?,
413 test_helper_search_paths: rabbitmq_default_test_helper_search_paths(),
414 dep_behaviours: Vec::new(),
415 option_types: Vec::new(),
416 suite_registration: Some(SuiteRegistrationDecl {
417 marker: RABBITMQ_SUITE_REGISTRATION_MARKER.to_owned(),
418 }),
419 })
420}
421
422fn build_cowboy_defaults() -> Result<FamilyDefaults, NameError> {
426 Ok(FamilyDefaults {
427 option_types: build_option_types(COWBOY_OPTION_TYPES)?,
428 ..Default::default()
429 })
430}
431
432static RA_DEFAULTS: LazyLock<FamilyDefaults> =
433 LazyLock::new(|| build_ra_defaults().expect("ra family default is valid"));
434static OSIRIS_DEFAULTS: LazyLock<FamilyDefaults> =
435 LazyLock::new(|| build_osiris_defaults().expect("osiris family default is valid"));
436static KHEPRI_DEFAULTS: LazyLock<FamilyDefaults> =
437 LazyLock::new(|| build_khepri_defaults().expect("khepri family default is valid"));
438static RABBITMQ_DEFAULTS: LazyLock<FamilyDefaults> =
439 LazyLock::new(|| build_rabbitmq_defaults().expect("rabbitmq family default is valid"));
440static COWBOY_DEFAULTS: LazyLock<FamilyDefaults> =
441 LazyLock::new(|| build_cowboy_defaults().expect("cowboy family default is valid"));
442
443fn rabbitmq_default_test_helper_search_paths() -> Vec<String> {
451 vec![
452 "deps/*/test".to_owned(),
453 "deps/*/src".to_owned(),
454 "deps/rabbitmq_ct_helpers/src".to_owned(),
455 ]
456}
457
458#[derive(Debug, Clone, Default, PartialEq, Eq)]
459pub struct FamilyDefaults {
460 pub wire_constants: Vec<WireConstantDecl>,
461 pub versioned_machines: Vec<VersionedMachineDecl>,
462 pub versioned_machine_impls: Vec<VersionedMachineImplDecl>,
463 pub test_helper_search_paths: Vec<String>,
466 pub dep_behaviours: Vec<String>,
471 pub option_types: Vec<OptionTypeDecl>,
474 pub suite_registration: Option<SuiteRegistrationDecl>,
477}
478
479#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct OptionTypeDecl {
483 pub module: ModuleName,
484 pub type_name: TypeName,
485}
486
487impl OptionTypeDecl {
488 pub fn new(module: &str, type_name: &str) -> Result<Self, NameError> {
489 Ok(Self {
490 module: ModuleName::from_str(module)?,
491 type_name: TypeName::from_str(type_name)?,
492 })
493 }
494}
495
496#[derive(Debug, Clone, PartialEq, Eq)]
499pub struct SuiteRegistrationDecl {
500 pub marker: String,
501}
502
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub struct WireConstantDecl {
505 pub module: ModuleName,
506 pub macros: Vec<MacroName>,
507}
508
509#[derive(Debug, Clone, PartialEq, Eq)]
510pub struct VersionedMachineDecl {
511 pub behaviour: BehaviourName,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct VersionedMachineImplDecl {
516 pub module: ModuleName,
517 pub version_function: FunctionName,
518 pub allow_state_flag_gating: bool,
519}
520
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522#[serde(deny_unknown_fields)]
523pub struct SeriesRaw {
524 pub name: String,
525 pub pins: Vec<PinRaw>,
526 #[serde(default)]
530 pub untracked_projects: Vec<String>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub target_repo_dir_path: Option<String>,
537 #[serde(default, skip_serializing_if = "Option::is_none")]
540 pub target_ref: Option<String>,
541}
542
543#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(untagged)]
546pub enum PinRaw {
547 Literal {
548 project: String,
549 tag: String,
550 },
551 Pattern {
552 project: String,
553 tag_pattern: String,
554 select: String,
555 },
556 SelfBranch {
557 project: String,
558 branch: String,
559 #[serde(default)]
560 repo_dir_path: Option<PathBuf>,
561 },
562 SelfSha {
563 project: String,
564 sha: String,
565 #[serde(default)]
566 repo_dir_path: Option<PathBuf>,
567 },
568}
569
570#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
571pub struct Defaults {
572 pub snapshot_dir: PathBuf,
573 pub fallback_branch: String,
574 pub scan_paths: Vec<String>,
575}
576
577vocabulary!(
578 pub enum Language: "language" {
579 Erlang => "erlang",
580 Elixir => "elixir",
581 }
582);
583
584vocabulary!(
585 pub enum ProjectLayout: "project layout" {
586 SingleApp => "single_app",
587 MultiApp => "multi_app",
588 ErlangOtp => "erlang_otp",
589 }
590);
591
592impl ProjectLayout {
593 pub fn defaults(self) -> LayoutDefaults {
594 match self {
595 Self::SingleApp | Self::MultiApp => LayoutDefaults::default(),
596 Self::ErlangOtp => LayoutDefaults {
597 app_roots: ERLANG_OTP_APP_ROOTS
598 .iter()
599 .map(|s| (*s).to_owned())
600 .collect(),
601 include_apps: Vec::new(),
602 exclude_apps: ERLANG_OTP_EXCLUDE_APPS
603 .iter()
604 .map(|s| ApplicationName::new(*s).expect("static name"))
605 .collect(),
606 excluded_subdirs: ERLANG_OTP_EXCLUDED_SUBDIRS
607 .iter()
608 .map(|s| (*s).to_owned())
609 .collect(),
610 tag_pattern: Some(TagGlob::new(ERLANG_OTP_TAG_PATTERN).expect("static tag glob")),
611 min_tag: Some(TagName::new(ERLANG_OTP_MIN_TAG).expect("static tag")),
612 tag_prefix: Some(ERLANG_OTP_TAG_PREFIX.to_owned()),
613 },
614 }
615 }
616}
617
618#[derive(Debug, Clone, PartialEq, Eq, Default)]
619pub struct LayoutDefaults {
620 pub app_roots: Vec<String>,
621 pub include_apps: Vec<ApplicationName>,
622 pub exclude_apps: Vec<ApplicationName>,
623 pub excluded_subdirs: Vec<String>,
624 pub tag_pattern: Option<TagGlob>,
625 pub min_tag: Option<TagName>,
626 pub tag_prefix: Option<String>,
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
632#[serde(tag = "kind", rename_all = "snake_case")]
633pub enum ProjectSource {
634 External {
635 git_url: PathBuf,
636 },
637 #[serde(rename = "self")]
638 SelfRepo,
639}
640
641impl ProjectSource {
642 #[must_use]
645 pub fn kind(&self) -> ProjectKind {
646 match self {
647 Self::External { .. } => ProjectKind::External,
648 Self::SelfRepo => ProjectKind::SelfRepo,
649 }
650 }
651
652 #[must_use]
654 pub fn git_url(&self) -> Option<&Path> {
655 match self {
656 Self::External { git_url } => Some(git_url),
657 Self::SelfRepo => None,
658 }
659 }
660}
661
662#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
663pub struct Project {
664 pub name: ProjectName,
665 #[serde(flatten)]
666 pub source: ProjectSource,
667 pub family: ProjectFamily,
668 pub language: Language,
669 pub tag_prefix: String,
670 pub public_modules: Vec<ModuleName>,
671 pub internal_modules: Vec<ModuleName>,
672 pub layout: ProjectLayout,
673 pub scan_paths: Vec<String>,
674 pub app_roots: Vec<String>,
675 pub include_apps: Vec<ApplicationName>,
676 pub exclude_apps: Vec<ApplicationName>,
677 pub excluded_subdirs: Vec<String>,
678 pub tag_pattern: Option<TagGlob>,
679 pub min_tag: Option<TagName>,
680 pub exclude_tag_markers: Vec<String>,
681 pub manifest: ManifestSource,
682}
683
684impl Project {
685 pub fn is_prerelease_tag(&self, tag: &TagName) -> bool {
687 self.exclude_tag_markers
688 .iter()
689 .any(|marker| tag.as_str().contains(marker))
690 }
691
692 pub fn is_self(&self) -> bool {
693 matches!(self.source, ProjectSource::SelfRepo)
694 }
695
696 pub fn owns_path(&self, path: &Path) -> bool {
699 let candidate = path.to_string_lossy();
700 self.scan_paths
701 .iter()
702 .chain(self.app_roots.iter())
703 .any(|raw| path_under_glob_prefix(&candidate, raw))
704 }
705}
706
707fn path_under_glob_prefix(candidate: &str, raw: &str) -> bool {
708 let prefix = glob_directory_prefix(raw).trim_end_matches('/');
709 if prefix.is_empty() {
710 return false;
711 }
712 if !candidate.starts_with(prefix) {
713 return false;
714 }
715 let rest = &candidate[prefix.len()..];
716 rest.is_empty() || rest.starts_with('/')
717}
718
719fn glob_directory_prefix(pattern: &str) -> &str {
721 let metachars = ['*', '?', '[', '{'];
722 match pattern.find(metachars) {
723 Some(i) => {
724 let head = &pattern[..i];
725 match head.rfind('/') {
726 Some(slash) => &head[..=slash],
727 None => "",
728 }
729 }
730 None => pattern,
731 }
732}
733
734#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
735pub struct Series {
736 pub name: SeriesName,
737 pub pins: Vec<PinSpec>,
738 #[serde(default)]
740 pub untracked_projects: Vec<ProjectName>,
741 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub target_repo_dir_path: Option<PathBuf>,
744 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub target_ref: Option<String>,
747}
748
749impl Series {
750 pub fn resolve_pins<M: StoreMode>(
752 &self,
753 store: &SnapshotStore<M>,
754 ) -> Result<Vec<Pin>, ConfigError> {
755 pin::resolve_all(&self.pins, store)
756 }
757}
758
759#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
760pub struct Config {
761 pub config_path: PathBuf,
762 pub defaults: Defaults,
763 #[serde(default)]
764 pub cache: CacheSettings,
765 pub projects: Vec<Project>,
766 pub series: Vec<Series>,
767 pub hosts: Vec<Host>,
768 pub suite_rules: Vec<ExtraRule>,
769 pub path_translations: PathTranslations,
770}
771
772impl Config {
773 pub fn self_project(&self) -> Option<&Project> {
776 self.projects.iter().find(|p| p.is_self())
777 }
778
779 pub fn load(path: &Path) -> Result<Self, ConfigError> {
780 if !path.exists() {
781 return Err(ConfigError::NotFound(path.to_path_buf()));
782 }
783 let text = fs::read_to_string(path)?;
784 let raw: ConfigFile = toml::from_str(&text)?;
785 Self::from_raw(path.to_path_buf(), raw)
786 }
787
788 pub fn from_raw(config_path: PathBuf, raw: ConfigFile) -> Result<Self, ConfigError> {
789 if raw.config_version != CONFIG_VERSION {
790 return Err(ConfigError::UnknownConfigVersion(raw.config_version));
791 }
792 let defaults = Defaults {
793 snapshot_dir: raw
794 .defaults
795 .snapshot_dir
796 .map(PathBuf::from)
797 .unwrap_or_else(|| PathBuf::from(DEFAULT_SNAPSHOT_DIR)),
798 fallback_branch: raw
799 .defaults
800 .fallback_branch
801 .unwrap_or_else(|| DEFAULT_FALLBACK_BRANCH.to_owned()),
802 scan_paths: raw
803 .defaults
804 .scan_paths
805 .unwrap_or_else(|| DEFAULT_SCAN_PATHS.iter().map(|s| (*s).to_owned()).collect()),
806 };
807 let cache = CacheSettings {
808 ttl_days: raw.cache.ttl_days.unwrap_or(DEFAULT_CACHE_TTL_DAYS),
809 };
810 let mut projects = Vec::with_capacity(raw.projects.len());
811 for p in raw.projects {
812 projects.push(parse_project(p, &defaults)?);
813 }
814 let self_projects: Vec<String> = projects
815 .iter()
816 .filter(|p| p.is_self())
817 .map(|p| p.name.to_string())
818 .collect();
819 if self_projects.len() > 1 {
820 return Err(ConfigError::MultipleSelfProjects {
821 projects: self_projects,
822 });
823 }
824 let project_is_self: BTreeMap<&ProjectName, bool> =
825 projects.iter().map(|p| (&p.name, p.is_self())).collect();
826 let mut series = Vec::with_capacity(raw.series.len());
827 for s in raw.series {
828 let mut pins = Vec::with_capacity(s.pins.len());
829 for pin in s.pins {
830 let spec = parse_pin(pin)?;
831 let Some(is_self) = project_is_self.get(spec.project()).copied() else {
832 return Err(ConfigError::SeriesPinsUnknownProject {
833 series: s.name.clone(),
834 project: spec.project().to_string(),
835 });
836 };
837 if spec.is_self() && !is_self {
838 return Err(ConfigError::SelfPinReferencesExternalProject {
839 project: spec.project().to_string(),
840 });
841 }
842 pins.push(spec);
843 }
844 let mut untracked_projects = Vec::with_capacity(s.untracked_projects.len());
845 for name in s.untracked_projects {
846 let project = ProjectName::new(name).map_err(ConfigError::Name)?;
847 if !project_is_self.contains_key(&project) {
848 return Err(ConfigError::SeriesPinsUnknownProject {
849 series: s.name.clone(),
850 project: project.to_string(),
851 });
852 }
853 untracked_projects.push(project);
854 }
855 series.push(Series {
856 name: SeriesName::new(s.name).map_err(ConfigError::Name)?,
857 pins,
858 untracked_projects,
859 target_repo_dir_path: s.target_repo_dir_path.map(PathBuf::from),
860 target_ref: s.target_ref,
861 });
862 }
863 let config_dir = config_path.parent().unwrap_or(Path::new("."));
864 let mut hosts: Vec<Host> = Vec::with_capacity(raw.hosts.len());
865 for h in raw.hosts {
866 let host = Host::resolve(h, config_dir, &projects)?;
867 if hosts.iter().any(|existing| existing.name == host.name) {
868 return Err(HostConfigError::Duplicate {
869 host: host.name.to_string(),
870 }
871 .into());
872 }
873 hosts.push(host);
874 }
875 let mut suite_rules = Vec::with_capacity(raw.suite_rules.len());
876 for (idx, r) in raw.suite_rules.into_iter().enumerate() {
877 suite_rules.push(parse_suite_rule(idx, r)?);
878 }
879 let path_translations = PathTranslations::from_config_stanzas(raw.path_translations)?;
880 Ok(Self {
881 config_path,
882 defaults,
883 cache,
884 projects,
885 series,
886 hosts,
887 suite_rules,
888 path_translations,
889 })
890 }
891
892 pub fn project(&self, name: &ProjectName) -> Result<&Project, ConfigError> {
893 self.projects
894 .iter()
895 .find(|p| &p.name == name)
896 .ok_or_else(|| ConfigError::UnknownProject(name.to_string()))
897 }
898
899 pub fn host(&self, name: &HostName) -> Result<&Host, ConfigError> {
900 self.hosts
901 .iter()
902 .find(|h| &h.name == name)
903 .ok_or_else(|| ConfigError::UnknownHost(name.to_string()))
904 }
905
906 pub fn series_by_name(&self, name: &SeriesName) -> Result<&Series, ConfigError> {
907 self.series
908 .iter()
909 .find(|s| &s.name == name)
910 .ok_or_else(|| ConfigError::UnknownSeries(name.to_string()))
911 }
912
913 pub fn projects_missing_from_series(&self, series: &Series) -> Vec<ProjectName> {
915 let pinned: BTreeSet<&ProjectName> = series.pins.iter().map(|p| p.project()).collect();
916 let mut missing: Vec<ProjectName> = self
917 .projects
918 .iter()
919 .map(|p| &p.name)
920 .filter(|n| !pinned.contains(*n))
921 .cloned()
922 .collect();
923 missing.sort();
924 missing
925 }
926
927 pub fn series_by_name_with_coverage_check(
930 &self,
931 name: &SeriesName,
932 ) -> Result<&Series, ConfigError> {
933 let series = self.series_by_name(name)?;
934 let missing = self.projects_missing_from_series(series);
935 if !missing.is_empty() {
936 let names: Vec<&str> = missing.iter().map(ProjectName::as_str).collect();
937 tracing::warn!(
938 series = %name,
939 missing_projects = ?names,
940 "series {} pins {} project(s); these configured projects have no pin: {}. Was this intentional?",
941 name, series.pins.len(), names.join(", ")
942 );
943 }
944 Ok(series)
945 }
946
947 pub fn snapshot_dir(&self) -> PathBuf {
948 let dir = &self.defaults.snapshot_dir;
949 if dir.is_absolute() {
950 dir.clone()
951 } else {
952 self.config_path
953 .parent()
954 .map(|p| p.join(dir))
955 .unwrap_or_else(|| dir.clone())
956 }
957 }
958}
959
960pub fn parse_suite_rules_toml(text: &str) -> Result<Vec<ExtraRule>, ConfigError> {
964 #[derive(serde::Deserialize)]
965 #[serde(deny_unknown_fields)]
966 struct RulesDoc {
967 #[serde(default)]
968 suite_rule: Vec<SuiteRuleRaw>,
969 }
970 let doc: RulesDoc = toml::from_str(text)?;
971 doc.suite_rule
972 .into_iter()
973 .enumerate()
974 .map(|(idx, raw)| parse_suite_rule(idx, raw))
975 .collect()
976}
977
978fn parse_suite_rule(idx: usize, raw: SuiteRuleRaw) -> Result<ExtraRule, ConfigError> {
979 let pattern = raw.when_modified_path_matches;
980 let compiled = Regex::new(&pattern).map_err(|e| ConfigError::SuiteRuleRegex {
981 rule_index: idx,
982 detail: e.to_string(),
983 })?;
984 let path_capture_names: Vec<String> = compiled
985 .capture_names()
986 .flatten()
987 .map(|s| s.to_owned())
988 .collect();
989 let line_match = match raw.when_modified_line_matches {
990 Some(line_pattern) => {
991 let line_compiled =
992 Regex::new(&line_pattern).map_err(|e| ConfigError::SuiteRuleRegex {
993 rule_index: idx,
994 detail: e.to_string(),
995 })?;
996 let line_captures: Vec<String> = line_compiled
997 .capture_names()
998 .flatten()
999 .map(|s| s.to_owned())
1000 .collect();
1001 Some(LineMatch {
1002 pattern: line_pattern,
1003 captures: line_captures,
1004 })
1005 }
1006 None => None,
1007 };
1008 let mut allowed_captures: Vec<String> = path_capture_names.clone();
1009 if let Some(lm) = &line_match {
1010 allowed_captures.extend(lm.captures.iter().cloned());
1011 }
1012 let mut templates: Vec<String> = Vec::new();
1013 templates.extend(raw.include_suite);
1014 templates.extend(raw.also);
1015 for t in &templates {
1016 if let Err(e) = validate_template_placeholders(t, &allowed_captures) {
1017 return Err(ConfigError::SuiteRuleUnknownPlaceholder {
1018 rule_index: idx,
1019 placeholder: e.placeholder,
1020 });
1021 }
1022 }
1023 if raw.include_suite_for_dep_modules && !allowed_captures.iter().any(|c| c == "dep") {
1024 return Err(ConfigError::SuiteRuleMissingDepCapture { rule_index: idx });
1025 }
1026 let name = raw.name.unwrap_or_else(|| format!("suite_rule_{idx}"));
1027 Ok(ExtraRule {
1028 name,
1029 trigger: ExtraRuleTrigger::PathRegex {
1030 pattern,
1031 captures: path_capture_names,
1032 },
1033 include_suites: Vec::new(),
1034 include_suite_templates: templates,
1035 line_match,
1036 include_suite_for_dep_modules: raw.include_suite_for_dep_modules,
1037 })
1038}
1039
1040fn module_names(raw: Option<Vec<String>>) -> Result<Vec<ModuleName>, ConfigError> {
1041 raw.unwrap_or_default()
1042 .into_iter()
1043 .map(|m| ModuleName::new(m).map_err(ConfigError::Name))
1044 .collect()
1045}
1046
1047fn parse_project(p: ProjectRaw, defaults: &Defaults) -> Result<Project, ConfigError> {
1048 let layout = p
1049 .layout
1050 .as_deref()
1051 .map(ProjectLayout::from_str)
1052 .transpose()?
1053 .unwrap_or(ProjectLayout::SingleApp);
1054 let language = p
1055 .language
1056 .as_deref()
1057 .map(Language::from_str)
1058 .transpose()?
1059 .unwrap_or(Language::Erlang);
1060 let layout_defaults = layout.defaults();
1061 let scan_paths = p.scan_paths.unwrap_or_else(|| match layout {
1062 ProjectLayout::SingleApp => defaults.scan_paths.clone(),
1063 ProjectLayout::MultiApp | ProjectLayout::ErlangOtp => Vec::new(),
1064 });
1065 let app_roots = p
1066 .app_roots
1067 .unwrap_or_else(|| layout_defaults.app_roots.clone());
1068 let include_apps = match p.include_apps {
1069 Some(v) => parse_app_names(v)?,
1070 None => layout_defaults.include_apps.clone(),
1071 };
1072 let exclude_apps = match p.exclude_apps {
1073 Some(v) => parse_app_names(v)?,
1074 None => layout_defaults.exclude_apps.clone(),
1075 };
1076 let excluded_subdirs = p
1077 .excluded_subdirs
1078 .unwrap_or_else(|| layout_defaults.excluded_subdirs.clone());
1079 let tag_pattern = match p.tag_pattern {
1080 Some(s) => Some(TagGlob::new(s).map_err(ConfigError::Name)?),
1081 None => layout_defaults.tag_pattern.clone(),
1082 };
1083 let min_tag = match p.min_tag {
1084 Some(s) => Some(TagName::new(s).map_err(ConfigError::Name)?),
1085 None => layout_defaults.min_tag.clone(),
1086 };
1087 let exclude_tag_markers = p.exclude_tag_markers.unwrap_or_else(|| {
1088 DEFAULT_EXCLUDE_TAG_MARKERS
1089 .iter()
1090 .map(|s| (*s).to_owned())
1091 .collect()
1092 });
1093 let manifest = p
1094 .manifest
1095 .as_deref()
1096 .map(ManifestSource::from_str)
1097 .transpose()?
1098 .unwrap_or_default();
1099 let name = ProjectName::new(p.name).map_err(ConfigError::Name)?;
1100 if matches!(layout, ProjectLayout::MultiApp | ProjectLayout::ErlangOtp) && app_roots.is_empty()
1101 {
1102 return Err(ConfigError::LayoutWithoutAppRoots {
1103 project: name.to_string(),
1104 layout: layout.label().to_owned(),
1105 });
1106 }
1107 let kind = p
1108 .kind
1109 .as_deref()
1110 .map(ProjectKind::from_str)
1111 .transpose()?
1112 .unwrap_or(ProjectKind::External);
1113 let family = p
1114 .family
1115 .as_deref()
1116 .map(ProjectFamily::from_str)
1117 .transpose()?
1118 .unwrap_or_default();
1119 let source = match (kind, p.git_url) {
1120 (ProjectKind::External, Some(g)) => ProjectSource::External {
1121 git_url: PathBuf::from(g),
1122 },
1123 (ProjectKind::External, None) => {
1124 return Err(ConfigError::ExternalProjectMissingGitUrl(name.to_string()));
1125 }
1126 (ProjectKind::SelfRepo, None) => ProjectSource::SelfRepo,
1127 (ProjectKind::SelfRepo, Some(_)) => {
1128 return Err(ConfigError::SelfProjectHasGitUrl(name.to_string()));
1129 }
1130 };
1131 Ok(Project {
1132 name,
1133 source,
1134 family,
1135 language,
1136 tag_prefix: p
1137 .tag_prefix
1138 .or(layout_defaults.tag_prefix)
1139 .unwrap_or_else(|| String::from("v")),
1140 public_modules: module_names(p.public_modules)?,
1141 internal_modules: module_names(p.internal_modules)?,
1142 layout,
1143 scan_paths,
1144 app_roots,
1145 include_apps,
1146 exclude_apps,
1147 excluded_subdirs,
1148 tag_pattern,
1149 min_tag,
1150 exclude_tag_markers,
1151 manifest,
1152 })
1153}
1154
1155fn parse_app_names(raw: Vec<String>) -> Result<Vec<ApplicationName>, ConfigError> {
1156 raw.into_iter()
1157 .map(|s| ApplicationName::new(s).map_err(ConfigError::Name))
1158 .collect()
1159}
1160
1161fn parse_pin(pin: PinRaw) -> Result<PinSpec, ConfigError> {
1162 match pin {
1163 PinRaw::SelfBranch {
1164 project,
1165 branch,
1166 repo_dir_path,
1167 } => {
1168 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1169 let git_ref = GitRef::new(branch).map_err(ConfigError::Name)?;
1170 Ok(PinSpec::SelfRef {
1171 project,
1172 git_ref,
1173 repo_dir_path,
1174 })
1175 }
1176 PinRaw::SelfSha {
1177 project,
1178 sha,
1179 repo_dir_path,
1180 } => {
1181 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1182 let git_ref = GitRef::new(sha).map_err(ConfigError::Name)?;
1183 Ok(PinSpec::SelfRef {
1184 project,
1185 git_ref,
1186 repo_dir_path,
1187 })
1188 }
1189 PinRaw::Literal { project, tag } => {
1190 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1191 let tag = TagName::new(tag).map_err(ConfigError::Name)?;
1192 Ok(PinSpec::literal(project, tag))
1193 }
1194 PinRaw::Pattern {
1195 project,
1196 tag_pattern,
1197 select,
1198 } => {
1199 let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1200 let pattern = TagGlob::new(tag_pattern).map_err(ConfigError::Name)?;
1201 let select = match select.as_str() {
1202 "latest" => PinSelect::Latest,
1203 "oldest" => PinSelect::Oldest,
1204 other => return Err(ConfigError::PinUnknownSelect(other.to_owned())),
1205 };
1206 Ok(PinSpec::pattern(project, pattern, select))
1207 }
1208 }
1209}