Skip to main content

backhopper_core/config/
mod.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! `backhopper.toml` schema and loader.
6
7mod 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
52/// Substrings that mark a tag as a pre-release. Tags containing any of these
53/// are excluded by default at snapshot-generation time.
54const 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/// TOML form of a path-pattern suite rule. Accepts `include_suite` as a
96/// single string or a list of strings; both end up in
97/// `ExtraRule::include_suite_templates`.
98#[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/// TOML form of the `[cache]` section.
144#[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/// Expiration policy for the workspace's on-disk caches. Time-based
151/// expiry is the whole policy: no LRU, no size caps. Expiration is
152/// hygiene, never correctness: the cache key components own correctness.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154pub struct CacheSettings {
155    /// Entries older than this many days expire. `0` disables expiry
156    /// (`cache prune` and `cache clear` still work). The default is
157    /// sized to RabbitMQ's monthly patch-release cadence: the real
158    /// reuse window is one round warming the next, four to six weeks.
159    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    /// Snapshots come from cloning `git_url` and reading its tags. The default.
199    External,
200    /// Snapshots are produced from the working repo at `--repo-dir-path`, at
201    /// a `git_ref` resolved per pin. There can be at most one self-project.
202    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/// Which family-specific detectors run on top of the generic surface
227/// checks. Declared per-project as `family = "ra"`. Orthogonal to
228/// `ProjectKind` and `ProjectLayout`.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
230#[serde(rename_all = "snake_case")]
231pub enum ProjectFamily {
232    /// No specialization. The default.
233    #[default]
234    Generic,
235    /// OTP itself.
236    ErlangOtp,
237    /// `rabbitmq/ra`: Raft consensus. Wire-bearing version macros in
238    /// `ra_log_segment`, `ra_log_wal`, `ra_log_snapshot`, `ra_snapshot`.
239    Ra,
240    /// `rabbitmq/osiris`: streaming log. Version constants in the
241    /// segment and index file headers.
242    Osiris,
243    /// `rabbitmq/khepri`: metadata store. Node payload version constants.
244    Khepri,
245    /// `rabbitmq/rabbitmq-server`: implements `ra_machine` via
246    /// `rabbit_fifo` and `rabbit_stream_coordinator`; carries `.schema`
247    /// files; uses `?LOG_*`, `rabbit_log:*`, and `rabbit_khepri`.
248    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    /// Wire-bearing macros, versioned-machine behaviours, and versioned-machine
282    /// implementer modules this family declares.
283    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
343/// Per-family relative-path globs the test-module resolver scans when
344/// looking up `helper_module:f/n` references in `_SUITE.erl` files.
345/// Globs are matched against `RelativePath` strings on the target
346/// tree; the first `*` segment expands to the application directory
347/// name (e.g. `deps/rabbit`, `deps/amqp_client`). Empty for
348/// `Generic`, so non-RabbitMQ projects do not get surprise verdict
349/// changes from a feature they did not opt into.
350fn 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    /// See `rabbitmq_default_test_helper_search_paths` for the
364    /// `ProjectFamily::Rabbitmq` default and the glob convention.
365    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    /// Tracked projects deliberately not pinned by this series (an
401    /// EOL branch predating the dep). Silences the doctor's
402    /// series-pin coverage warning for the named pairs.
403    #[serde(default)]
404    pub untracked_projects: Vec<String>,
405    /// The target checkout that holds this series' branch, for
406    /// `check cascade` and for single-series verbs run without
407    /// `--target-repo-dir-path`. Authoritative for backhopper; q-port
408    /// holds its own copy and cross-checks it.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub target_repo_dir_path: Option<String>,
411    /// Ref within that checkout; `HEAD` when omitted, matching the
412    /// flag's default.
413    #[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// deny_unknown_fields is inert on untagged enums, so a typo'd pin key is not rejected here
419#[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    /// `None` for self-projects; the repo is the runtime `--repo-dir-path`.
557    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    /// True if `tag` contains any of this project's pre-release markers.
577    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    /// `git_url` is only present for external projects. Returns a clear
584    /// error when callers ask for it on a self-project.
585    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    /// Prefix-matches `path` against `scan_paths` and `app_roots`,
596    /// stripping glob suffixes.
597    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
618// Return the literal directory prefix before any glob metacharacter
619fn 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    /// Tracked projects this series deliberately does not pin.
638    #[serde(default)]
639    pub untracked_projects: Vec<ProjectName>,
640    /// The target checkout that holds this series' branch.
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub target_repo_dir_path: Option<PathBuf>,
643    /// Ref within that checkout; `HEAD` when omitted.
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub target_ref: Option<String>,
646}
647
648impl Series {
649    /// Resolve every pin spec against `store`, returning concrete `Pin`s.
650    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    /// Projects configured globally but not pinned by `series`, sorted alphabetically.
783    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    /// Like [`series_by_name`](Self::series_by_name) but also emits a
797    /// `tracing::warn!` per configured project the series does not pin.
798    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
829/// Parse a standalone TOML file of extra suite rules: the same
830/// `[[suite_rule]]` shape the config accepts, validated identically so
831/// the trailing-slash and unknown-placeholder mistakes fail fast.
832pub 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}