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