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