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 host;
8mod path_translation;
9
10pub use host::{
11    ERLANG_OTP_TAG_PREFIX, ErlangSupport, ErlangSupportRaw, Host, HostConfigError, HostPinsRaw,
12    HostRaw, MakefileVariableRaw, PinSource, PinSourceKind, ReleaseRootsRaw,
13};
14pub use path_translation::{
15    PathTranslation, PathTranslations, TranslationDirection, TranslationOrigin,
16};
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::fs;
20use std::path::{Path, PathBuf};
21use std::str::FromStr;
22use std::sync::LazyLock;
23
24use regex::Regex;
25use serde::{Deserialize, Deserializer, Serialize};
26
27use crate::errors::{ConfigError, NameError};
28use crate::model::app_manifest::ManifestSource;
29use crate::model::names::{
30    ApplicationName, BehaviourName, FunctionName, GitRef, HostName, MacroName, ModuleName,
31    ProjectName, SeriesName, TagGlob, TagName, TypeName, vocabulary,
32};
33use crate::model::pin::{self, Pin, PinSelect, PinSpec};
34use crate::store::{SnapshotStore, StoreMode};
35use crate::suites::rules::validate_template_placeholders;
36use crate::suites::{ExtraRule, ExtraRuleTrigger, LineMatch};
37
38pub const CONFIG_VERSION: u32 = 1;
39
40const DEFAULT_SCAN_PATHS: &[&str] = &["src/**/*.erl", "src/**/*.ex", "include/**/*.hrl"];
41const DEFAULT_SNAPSHOT_DIR: &str = "snapshots";
42const DEFAULT_FALLBACK_BRANCH: &str = "main";
43
44const ERLANG_OTP_APP_ROOTS: &[&str] = &["lib/*", "erts/preloaded"];
45const ERLANG_OTP_EXCLUDE_APPS: &[&str] = &[
46    "odbc",
47    "snmp",
48    "ssh",
49    "tftp",
50    "ftp",
51    "wx",
52    "megaco",
53    "edoc",
54    "jinterface",
55    "diameter",
56];
57const ERLANG_OTP_EXCLUDED_SUBDIRS: &[&str] = &["doc", "example", "examples", "test"];
58const ERLANG_OTP_TAG_PATTERN: &str = "OTP-*";
59const ERLANG_OTP_MIN_TAG: &str = "OTP-26.0";
60
61/// Substrings that mark a tag as a pre-release. Tags containing any of these
62/// are excluded by default at snapshot-generation time.
63const DEFAULT_EXCLUDE_TAG_MARKERS: &[&str] = &["-rc", "-alpha", "-beta", "-pre"];
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct ConfigFile {
68    #[serde(default = "default_config_version")]
69    pub config_version: u32,
70
71    #[serde(default)]
72    pub defaults: DefaultsRaw,
73
74    #[serde(default)]
75    pub cache: CacheRaw,
76
77    #[serde(default, rename = "project")]
78    pub projects: Vec<ProjectRaw>,
79
80    #[serde(default, rename = "series")]
81    pub series: Vec<SeriesRaw>,
82
83    #[serde(default, rename = "host")]
84    pub hosts: Vec<HostRaw>,
85
86    #[serde(default, rename = "suite_rule")]
87    pub suite_rules: Vec<SuiteRuleRaw>,
88
89    #[serde(default, rename = "path_translation")]
90    pub path_translations: Vec<PathTranslationRaw>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct PathTranslationRaw {
96    pub name: String,
97    #[serde(default = "default_translation_direction")]
98    pub direction: String,
99    pub source_prefix: String,
100    pub target_prefix: String,
101}
102
103fn default_translation_direction() -> String {
104    "source_to_target".to_owned()
105}
106
107/// TOML form of a path-pattern suite rule. Accepts `include_suite` as a
108/// single string or a list of strings; both end up in
109/// `ExtraRule::include_suite_templates`.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(deny_unknown_fields)]
112pub struct SuiteRuleRaw {
113    pub name: Option<String>,
114    pub when_modified_path_matches: String,
115    #[serde(default)]
116    pub when_modified_line_matches: Option<String>,
117    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
118    pub include_suite: Vec<String>,
119    #[serde(default, deserialize_with = "deserialize_string_or_vec")]
120    pub also: Vec<String>,
121    #[serde(default)]
122    pub include_suite_for_dep_modules: bool,
123}
124
125#[derive(Deserialize)]
126#[serde(untagged)]
127enum StringOrVec {
128    One(String),
129    Many(Vec<String>),
130}
131
132fn deserialize_string_or_vec<'de, D>(d: D) -> Result<Vec<String>, D::Error>
133where
134    D: Deserializer<'de>,
135{
136    Option::<StringOrVec>::deserialize(d).map(|opt| match opt {
137        None => Vec::new(),
138        Some(StringOrVec::One(s)) => vec![s],
139        Some(StringOrVec::Many(v)) => v,
140    })
141}
142
143fn default_config_version() -> u32 {
144    CONFIG_VERSION
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct DefaultsRaw {
150    pub snapshot_dir: Option<String>,
151    pub fallback_branch: Option<String>,
152    pub scan_paths: Option<Vec<String>>,
153}
154
155/// TOML form of the `[cache]` section.
156#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
157#[serde(deny_unknown_fields)]
158pub struct CacheRaw {
159    pub ttl_days: Option<u32>,
160}
161
162/// Expiration policy for the workspace's on-disk caches. Time-based
163/// expiry is the whole policy: no LRU, no size caps. Expiration is
164/// hygiene, never correctness: the cache key components own correctness.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
166pub struct CacheSettings {
167    /// Entries older than this many days expire. `0` disables expiry
168    /// (`cache prune` and `cache clear` still work). The default is
169    /// sized to RabbitMQ's monthly patch-release cadence: the real
170    /// reuse window is one round warming the next, four to six weeks.
171    pub ttl_days: u32,
172}
173
174pub const DEFAULT_CACHE_TTL_DAYS: u32 = 42;
175
176impl Default for CacheSettings {
177    fn default() -> Self {
178        Self {
179            ttl_days: DEFAULT_CACHE_TTL_DAYS,
180        }
181    }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(deny_unknown_fields)]
186pub struct ProjectRaw {
187    pub name: String,
188    pub git_url: Option<String>,
189    pub kind: Option<String>,
190    pub family: Option<String>,
191    pub language: Option<String>,
192    pub tag_prefix: Option<String>,
193    pub public_modules: Option<Vec<String>>,
194    pub internal_modules: Option<Vec<String>>,
195    pub scan_paths: Option<Vec<String>>,
196    pub layout: Option<String>,
197    pub app_roots: Option<Vec<String>>,
198    pub include_apps: Option<Vec<String>>,
199    pub exclude_apps: Option<Vec<String>>,
200    pub excluded_subdirs: Option<Vec<String>>,
201    pub tag_pattern: Option<String>,
202    #[serde(alias = "oldest_tag")]
203    pub min_tag: Option<String>,
204    pub exclude_tag_markers: Option<Vec<String>>,
205    pub manifest: Option<String>,
206}
207
208vocabulary!(
209    pub enum ProjectKind: "project kind" {
210        /// Snapshots come from cloning `git_url` and reading its tags. The default.
211        External => "external",
212        /// Snapshots are produced from the working repo at `--repo-dir-path`, at
213        /// a `git_ref` resolved per pin. There can be at most one self-project.
214        SelfRepo => "self",
215    }
216);
217
218vocabulary!(
219    /// Which family-specific detectors run on top of the generic surface
220    /// checks. Declared per-project as `family = "ra"`. Orthogonal to
221    /// `ProjectKind` and `ProjectLayout`.
222    #[derive(Default)]
223    pub enum ProjectFamily: "project family" {
224        /// No specialization. The default.
225        #[default]
226        Generic => "generic",
227        /// OTP itself.
228        ErlangOtp => "erlang_otp",
229        /// `rabbitmq/ra`: Raft consensus. Wire-bearing version macros in
230        /// `ra_log_segment`, `ra_log_wal`, `ra_log_snapshot`, `ra_snapshot`.
231        Ra => "ra",
232        /// `rabbitmq/osiris`: streaming log. Version constants in the
233        /// segment and index file headers.
234        Osiris => "osiris",
235        /// `rabbitmq/khepri`: metadata store. Node payload version constants.
236        Khepri => "khepri",
237        /// `rabbitmq/rabbitmq-server`: implements `ra_machine` via
238        /// `rabbit_fifo` and `rabbit_stream_coordinator`; carries `.schema`
239        /// files; uses `?LOG_*`, `rabbit_log:*`, and `rabbit_khepri`.
240        Rabbitmq => "rabbitmq",
241        /// `ninenines/cowboy`: HTTP server. Option-map key accretion in
242        /// `cowboy_http`, `cowboy_http2`, and `cowboy_websocket`.
243        Cowboy => "cowboy",
244    }
245);
246
247impl ProjectFamily {
248    /// Wire-bearing macros, versioned-machine behaviours, and versioned-machine
249    /// implementer modules this family declares.
250    pub fn defaults(self) -> FamilyDefaults {
251        match self {
252            Self::Generic | Self::ErlangOtp => FamilyDefaults::default(),
253            Self::Ra => RA_DEFAULTS.clone(),
254            Self::Osiris => OSIRIS_DEFAULTS.clone(),
255            Self::Khepri => KHEPRI_DEFAULTS.clone(),
256            Self::Rabbitmq => RABBITMQ_DEFAULTS.clone(),
257            Self::Cowboy => COWBOY_DEFAULTS.clone(),
258        }
259    }
260}
261
262const RA_WIRE_CONSTANTS: &[(&str, &[&str])] = &[
263    ("ra_log_segment", &["VERSION", "MAGIC"]),
264    ("ra_log_wal", &["CURRENT_VERSION", "MAGIC"]),
265    ("ra_log_snapshot", &["VERSION", "MAGIC"]),
266    ("ra_snapshot", &["IDX_VERSION", "IDX_MAGIC"]),
267    // Replaces the earlier lone `RA_PROTO_VERSION` entry: the
268    // `C_RA_LOG_*` counter indexes renumbered three times across the
269    // pinned range and a positional counter reader silently misreads
270    // across a backport. `C_RA_SRV_*` macros are deliberately omitted:
271    // their bodies are `?C_RA_LOG_RESERVED + n`, an expression the 019
272    // value normalization stores as opaque text that never changes
273    // when the base moves.
274    (
275        "ra",
276        &[
277            "RA_PROTO_VERSION",
278            "C_RA_LOG_WRITE_OPS",
279            "C_RA_LOG_WRITE_RESENDS",
280            "C_RA_LOG_READ_OPS",
281            "C_RA_LOG_READ_CACHE",
282            "C_RA_LOG_READ_MEM_TBL",
283            "C_RA_LOG_READ_CLOSED_MEM_TBL",
284            "C_RA_LOG_READ_SEGMENT",
285            "C_RA_LOG_FETCH_TERM",
286            "C_RA_LOG_SNAPSHOTS_WRITTEN",
287            "C_RA_LOG_SNAPSHOTS_INSTALLED",
288            "C_RA_LOG_SNAPSHOT_BYTES_WRITTEN",
289            "C_RA_LOG_OPEN_SEGMENTS",
290            "C_RA_LOG_CHECKPOINTS_WRITTEN",
291            "C_RA_LOG_CHECKPOINT_BYTES_WRITTEN",
292            "C_RA_LOG_CHECKPOINTS_PROMOTED",
293            "C_RA_LOG_COMPACTIONS_MINOR_COUNT",
294            "C_RA_LOG_COMPACTIONS_MAJOR_COUNT",
295            "C_RA_LOG_COMPACTIONS_SEGMENTS_WRITTEN",
296            "C_RA_LOG_COMPACTIONS_SEGMENTS_COMPACTED",
297            "C_RA_LOG_RESERVED",
298        ],
299    ),
300];
301const RA_VERSIONED_MACHINES: &[&str] = &["ra_machine"];
302const RA_DEP_BEHAVIOURS: &[&str] = &["ra_machine", "ra_snapshot"];
303
304const OSIRIS_WIRE_CONSTANTS: &[(&str, &[&str])] = &[(
305    "osiris",
306    &[
307        "MAGIC",
308        "VERSION",
309        "IDX_VERSION",
310        "LOG_VERSION",
311        "IDX_HEADER",
312        "LOG_HEADER",
313    ],
314)];
315
316const KHEPRI_WIRE_CONSTANTS: &[(&str, &[&str])] = &[(
317    "khepri_node",
318    &["INIT_DATA_VERSION", "INIT_CHILD_LIST_VERSION"],
319)];
320
321const RABBITMQ_VERSIONED_MACHINE_IMPLS: &[(&str, &str, bool)] = &[
322    ("rabbit_fifo", "version", true),
323    ("rabbit_stream_coordinator", "version", false),
324];
325
326fn build_wire_constants(rows: &[(&str, &[&str])]) -> Result<Vec<WireConstantDecl>, NameError> {
327    rows.iter()
328        .map(|(module, macros)| {
329            Ok(WireConstantDecl {
330                module: ModuleName::from_str(module)?,
331                macros: macros
332                    .iter()
333                    .map(|m| MacroName::from_str(m))
334                    .collect::<Result<_, _>>()?,
335            })
336        })
337        .collect()
338}
339
340fn build_versioned_machines(behaviours: &[&str]) -> Result<Vec<VersionedMachineDecl>, NameError> {
341    behaviours
342        .iter()
343        .map(|b| {
344            Ok(VersionedMachineDecl {
345                behaviour: BehaviourName::from_str(b)?,
346            })
347        })
348        .collect()
349}
350
351fn build_versioned_machine_impls(
352    rows: &[(&str, &str, bool)],
353) -> Result<Vec<VersionedMachineImplDecl>, NameError> {
354    rows.iter()
355        .map(|(module, version_function, allow_state_flag_gating)| {
356            Ok(VersionedMachineImplDecl {
357                module: ModuleName::from_str(module)?,
358                version_function: FunctionName::from_str(version_function)?,
359                allow_state_flag_gating: *allow_state_flag_gating,
360            })
361        })
362        .collect()
363}
364
365const RA_OPTION_TYPES: &[(&str, &str)] =
366    &[("ra_system", "config"), ("ra_server", "ra_server_config")];
367
368const COWBOY_OPTION_TYPES: &[(&str, &str)] = &[
369    ("cowboy_http", "opts"),
370    ("cowboy_http2", "opts"),
371    ("cowboy_websocket", "opts"),
372];
373
374const RABBITMQ_SUITE_REGISTRATION_MARKER: &str = "PARALLEL_CT";
375
376fn build_option_types(rows: &[(&str, &str)]) -> Result<Vec<OptionTypeDecl>, NameError> {
377    rows.iter()
378        .map(|(module, type_name)| OptionTypeDecl::new(module, type_name))
379        .collect()
380}
381
382fn build_ra_defaults() -> Result<FamilyDefaults, NameError> {
383    Ok(FamilyDefaults {
384        wire_constants: build_wire_constants(RA_WIRE_CONSTANTS)?,
385        versioned_machines: build_versioned_machines(RA_VERSIONED_MACHINES)?,
386        versioned_machine_impls: Vec::new(),
387        test_helper_search_paths: Vec::new(),
388        dep_behaviours: RA_DEP_BEHAVIOURS.iter().map(|s| (*s).to_owned()).collect(),
389        option_types: build_option_types(RA_OPTION_TYPES)?,
390        suite_registration: None,
391    })
392}
393
394fn build_osiris_defaults() -> Result<FamilyDefaults, NameError> {
395    Ok(FamilyDefaults {
396        wire_constants: build_wire_constants(OSIRIS_WIRE_CONSTANTS)?,
397        ..Default::default()
398    })
399}
400
401fn build_khepri_defaults() -> Result<FamilyDefaults, NameError> {
402    Ok(FamilyDefaults {
403        wire_constants: build_wire_constants(KHEPRI_WIRE_CONSTANTS)?,
404        ..Default::default()
405    })
406}
407
408fn build_rabbitmq_defaults() -> Result<FamilyDefaults, NameError> {
409    Ok(FamilyDefaults {
410        wire_constants: Vec::new(),
411        versioned_machines: Vec::new(),
412        versioned_machine_impls: build_versioned_machine_impls(RABBITMQ_VERSIONED_MACHINE_IMPLS)?,
413        test_helper_search_paths: rabbitmq_default_test_helper_search_paths(),
414        dep_behaviours: Vec::new(),
415        option_types: Vec::new(),
416        suite_registration: Some(SuiteRegistrationDecl {
417            marker: RABBITMQ_SUITE_REGISTRATION_MARKER.to_owned(),
418        }),
419    })
420}
421
422// `dep_behaviours` deliberately stays empty for Cowboy in v1:
423// `cowboy_websocket` and `cowboy_handler` callbacks have been stable
424// across the pinned range, and C1's evidence is Ra-shaped.
425fn build_cowboy_defaults() -> Result<FamilyDefaults, NameError> {
426    Ok(FamilyDefaults {
427        option_types: build_option_types(COWBOY_OPTION_TYPES)?,
428        ..Default::default()
429    })
430}
431
432static RA_DEFAULTS: LazyLock<FamilyDefaults> =
433    LazyLock::new(|| build_ra_defaults().expect("ra family default is valid"));
434static OSIRIS_DEFAULTS: LazyLock<FamilyDefaults> =
435    LazyLock::new(|| build_osiris_defaults().expect("osiris family default is valid"));
436static KHEPRI_DEFAULTS: LazyLock<FamilyDefaults> =
437    LazyLock::new(|| build_khepri_defaults().expect("khepri family default is valid"));
438static RABBITMQ_DEFAULTS: LazyLock<FamilyDefaults> =
439    LazyLock::new(|| build_rabbitmq_defaults().expect("rabbitmq family default is valid"));
440static COWBOY_DEFAULTS: LazyLock<FamilyDefaults> =
441    LazyLock::new(|| build_cowboy_defaults().expect("cowboy family default is valid"));
442
443/// Per-family relative-path globs the test-module resolver scans when
444/// looking up `helper_module:f/n` references in `_SUITE.erl` files.
445/// Globs are matched against `RelativePath` strings on the target
446/// tree; the first `*` segment expands to the application directory
447/// name (e.g. `deps/rabbit`, `deps/amqp_client`). Empty for
448/// `Generic`, so non-RabbitMQ projects do not get surprise verdict
449/// changes from a feature they did not opt into.
450fn rabbitmq_default_test_helper_search_paths() -> Vec<String> {
451    vec![
452        "deps/*/test".to_owned(),
453        "deps/*/src".to_owned(),
454        "deps/rabbitmq_ct_helpers/src".to_owned(),
455    ]
456}
457
458#[derive(Debug, Clone, Default, PartialEq, Eq)]
459pub struct FamilyDefaults {
460    pub wire_constants: Vec<WireConstantDecl>,
461    pub versioned_machines: Vec<VersionedMachineDecl>,
462    pub versioned_machine_impls: Vec<VersionedMachineImplDecl>,
463    /// See `rabbitmq_default_test_helper_search_paths` for the
464    /// `ProjectFamily::Rabbitmq` default and the glob convention.
465    pub test_helper_search_paths: Vec<String>,
466    /// Behaviour modules owned by this family whose implementers in
467    /// *other* projects should be checked against this project's
468    /// pinned callback surface (C1). Empty for every family but `Ra`:
469    /// entries land when a real drift case bites, not on speculation.
470    pub dep_behaviours: Vec<String>,
471    /// Exported map types whose top-level keys form a recognized
472    /// option vocabulary for this family (C2).
473    pub option_types: Vec<OptionTypeDecl>,
474    /// When set, added test suites in an app whose Makefile matches
475    /// the marker must be referenced from that Makefile (C5).
476    pub suite_registration: Option<SuiteRegistrationDecl>,
477}
478
479/// One exported map type declared as a family's recognized option
480/// vocabulary: `module:type_name/0`.
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct OptionTypeDecl {
483    pub module: ModuleName,
484    pub type_name: TypeName,
485}
486
487impl OptionTypeDecl {
488    pub fn new(module: &str, type_name: &str) -> Result<Self, NameError> {
489        Ok(Self {
490            module: ModuleName::from_str(module)?,
491            type_name: TypeName::from_str(type_name)?,
492        })
493    }
494}
495
496/// Gates C5's suite-registration check: apps whose Makefile carries
497/// `marker` dispatch suites by explicit registration.
498#[derive(Debug, Clone, PartialEq, Eq)]
499pub struct SuiteRegistrationDecl {
500    pub marker: String,
501}
502
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub struct WireConstantDecl {
505    pub module: ModuleName,
506    pub macros: Vec<MacroName>,
507}
508
509#[derive(Debug, Clone, PartialEq, Eq)]
510pub struct VersionedMachineDecl {
511    pub behaviour: BehaviourName,
512}
513
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct VersionedMachineImplDecl {
516    pub module: ModuleName,
517    pub version_function: FunctionName,
518    pub allow_state_flag_gating: bool,
519}
520
521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522#[serde(deny_unknown_fields)]
523pub struct SeriesRaw {
524    pub name: String,
525    pub pins: Vec<PinRaw>,
526    /// Tracked projects deliberately not pinned by this series (an
527    /// EOL branch predating the dep). Silences the doctor's
528    /// series-pin coverage warning for the named pairs.
529    #[serde(default)]
530    pub untracked_projects: Vec<String>,
531    /// The target checkout that holds this series' branch, for
532    /// `check cascade` and for single-series verbs run without
533    /// `--target-repo-dir-path`. Authoritative for backhopper; q-port
534    /// holds its own copy and cross-checks it.
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub target_repo_dir_path: Option<String>,
537    /// Ref within that checkout; `HEAD` when omitted, matching the
538    /// flag's default.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub target_ref: Option<String>,
541}
542
543#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
544// deny_unknown_fields is inert on untagged enums, so a typo'd pin key is not rejected here
545#[serde(untagged)]
546pub enum PinRaw {
547    Literal {
548        project: String,
549        tag: String,
550    },
551    Pattern {
552        project: String,
553        tag_pattern: String,
554        select: String,
555    },
556    SelfBranch {
557        project: String,
558        branch: String,
559        #[serde(default)]
560        repo_dir_path: Option<PathBuf>,
561    },
562    SelfSha {
563        project: String,
564        sha: String,
565        #[serde(default)]
566        repo_dir_path: Option<PathBuf>,
567    },
568}
569
570#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
571pub struct Defaults {
572    pub snapshot_dir: PathBuf,
573    pub fallback_branch: String,
574    pub scan_paths: Vec<String>,
575}
576
577vocabulary!(
578    pub enum Language: "language" {
579        Erlang => "erlang",
580        Elixir => "elixir",
581    }
582);
583
584vocabulary!(
585    pub enum ProjectLayout: "project layout" {
586        SingleApp => "single_app",
587        MultiApp => "multi_app",
588        ErlangOtp => "erlang_otp",
589    }
590);
591
592impl ProjectLayout {
593    pub fn defaults(self) -> LayoutDefaults {
594        match self {
595            Self::SingleApp | Self::MultiApp => LayoutDefaults::default(),
596            Self::ErlangOtp => LayoutDefaults {
597                app_roots: ERLANG_OTP_APP_ROOTS
598                    .iter()
599                    .map(|s| (*s).to_owned())
600                    .collect(),
601                include_apps: Vec::new(),
602                exclude_apps: ERLANG_OTP_EXCLUDE_APPS
603                    .iter()
604                    .map(|s| ApplicationName::new(*s).expect("static name"))
605                    .collect(),
606                excluded_subdirs: ERLANG_OTP_EXCLUDED_SUBDIRS
607                    .iter()
608                    .map(|s| (*s).to_owned())
609                    .collect(),
610                tag_pattern: Some(TagGlob::new(ERLANG_OTP_TAG_PATTERN).expect("static tag glob")),
611                min_tag: Some(TagName::new(ERLANG_OTP_MIN_TAG).expect("static tag")),
612                tag_prefix: Some(ERLANG_OTP_TAG_PREFIX.to_owned()),
613            },
614        }
615    }
616}
617
618#[derive(Debug, Clone, PartialEq, Eq, Default)]
619pub struct LayoutDefaults {
620    pub app_roots: Vec<String>,
621    pub include_apps: Vec<ApplicationName>,
622    pub exclude_apps: Vec<ApplicationName>,
623    pub excluded_subdirs: Vec<String>,
624    pub tag_pattern: Option<TagGlob>,
625    pub min_tag: Option<TagName>,
626    pub tag_prefix: Option<String>,
627}
628
629/// A project's snapshots come from cloning `git_url`, or from the working
630/// repo at runtime's `--repo-dir-path`: never both, never neither.
631#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
632#[serde(tag = "kind", rename_all = "snake_case")]
633pub enum ProjectSource {
634    External {
635        git_url: PathBuf,
636    },
637    #[serde(rename = "self")]
638    SelfRepo,
639}
640
641impl ProjectSource {
642    /// The raw TOML vocabulary this arm was parsed from, for the surfaces
643    /// that print a project's kind as a label.
644    #[must_use]
645    pub fn kind(&self) -> ProjectKind {
646        match self {
647            Self::External { .. } => ProjectKind::External,
648            Self::SelfRepo => ProjectKind::SelfRepo,
649        }
650    }
651
652    /// The clone URL of an external project; a self-project has none.
653    #[must_use]
654    pub fn git_url(&self) -> Option<&Path> {
655        match self {
656            Self::External { git_url } => Some(git_url),
657            Self::SelfRepo => None,
658        }
659    }
660}
661
662#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
663pub struct Project {
664    pub name: ProjectName,
665    #[serde(flatten)]
666    pub source: ProjectSource,
667    pub family: ProjectFamily,
668    pub language: Language,
669    pub tag_prefix: String,
670    pub public_modules: Vec<ModuleName>,
671    pub internal_modules: Vec<ModuleName>,
672    pub layout: ProjectLayout,
673    pub scan_paths: Vec<String>,
674    pub app_roots: Vec<String>,
675    pub include_apps: Vec<ApplicationName>,
676    pub exclude_apps: Vec<ApplicationName>,
677    pub excluded_subdirs: Vec<String>,
678    pub tag_pattern: Option<TagGlob>,
679    pub min_tag: Option<TagName>,
680    pub exclude_tag_markers: Vec<String>,
681    pub manifest: ManifestSource,
682}
683
684impl Project {
685    /// True if `tag` contains any of this project's pre-release markers.
686    pub fn is_prerelease_tag(&self, tag: &TagName) -> bool {
687        self.exclude_tag_markers
688            .iter()
689            .any(|marker| tag.as_str().contains(marker))
690    }
691
692    pub fn is_self(&self) -> bool {
693        matches!(self.source, ProjectSource::SelfRepo)
694    }
695
696    /// Prefix-matches `path` against `scan_paths` and `app_roots`,
697    /// stripping glob suffixes.
698    pub fn owns_path(&self, path: &Path) -> bool {
699        let candidate = path.to_string_lossy();
700        self.scan_paths
701            .iter()
702            .chain(self.app_roots.iter())
703            .any(|raw| path_under_glob_prefix(&candidate, raw))
704    }
705}
706
707fn path_under_glob_prefix(candidate: &str, raw: &str) -> bool {
708    let prefix = glob_directory_prefix(raw).trim_end_matches('/');
709    if prefix.is_empty() {
710        return false;
711    }
712    if !candidate.starts_with(prefix) {
713        return false;
714    }
715    let rest = &candidate[prefix.len()..];
716    rest.is_empty() || rest.starts_with('/')
717}
718
719// Return the literal directory prefix before any glob metacharacter
720fn glob_directory_prefix(pattern: &str) -> &str {
721    let metachars = ['*', '?', '[', '{'];
722    match pattern.find(metachars) {
723        Some(i) => {
724            let head = &pattern[..i];
725            match head.rfind('/') {
726                Some(slash) => &head[..=slash],
727                None => "",
728            }
729        }
730        None => pattern,
731    }
732}
733
734#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
735pub struct Series {
736    pub name: SeriesName,
737    pub pins: Vec<PinSpec>,
738    /// Tracked projects this series deliberately does not pin.
739    #[serde(default)]
740    pub untracked_projects: Vec<ProjectName>,
741    /// The target checkout that holds this series' branch.
742    #[serde(default, skip_serializing_if = "Option::is_none")]
743    pub target_repo_dir_path: Option<PathBuf>,
744    /// Ref within that checkout; `HEAD` when omitted.
745    #[serde(default, skip_serializing_if = "Option::is_none")]
746    pub target_ref: Option<String>,
747}
748
749impl Series {
750    /// Resolve every pin spec against `store`, returning concrete `Pin`s.
751    pub fn resolve_pins<M: StoreMode>(
752        &self,
753        store: &SnapshotStore<M>,
754    ) -> Result<Vec<Pin>, ConfigError> {
755        pin::resolve_all(&self.pins, store)
756    }
757}
758
759#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
760pub struct Config {
761    pub config_path: PathBuf,
762    pub defaults: Defaults,
763    #[serde(default)]
764    pub cache: CacheSettings,
765    pub projects: Vec<Project>,
766    pub series: Vec<Series>,
767    pub hosts: Vec<Host>,
768    pub suite_rules: Vec<ExtraRule>,
769    pub path_translations: PathTranslations,
770}
771
772impl Config {
773    /// The one project whose snapshots come from the working repo, if any.
774    /// `from_raw` refuses a config with more than one.
775    pub fn self_project(&self) -> Option<&Project> {
776        self.projects.iter().find(|p| p.is_self())
777    }
778
779    pub fn load(path: &Path) -> Result<Self, ConfigError> {
780        if !path.exists() {
781            return Err(ConfigError::NotFound(path.to_path_buf()));
782        }
783        let text = fs::read_to_string(path)?;
784        let raw: ConfigFile = toml::from_str(&text)?;
785        Self::from_raw(path.to_path_buf(), raw)
786    }
787
788    pub fn from_raw(config_path: PathBuf, raw: ConfigFile) -> Result<Self, ConfigError> {
789        if raw.config_version != CONFIG_VERSION {
790            return Err(ConfigError::UnknownConfigVersion(raw.config_version));
791        }
792        let defaults = Defaults {
793            snapshot_dir: raw
794                .defaults
795                .snapshot_dir
796                .map(PathBuf::from)
797                .unwrap_or_else(|| PathBuf::from(DEFAULT_SNAPSHOT_DIR)),
798            fallback_branch: raw
799                .defaults
800                .fallback_branch
801                .unwrap_or_else(|| DEFAULT_FALLBACK_BRANCH.to_owned()),
802            scan_paths: raw
803                .defaults
804                .scan_paths
805                .unwrap_or_else(|| DEFAULT_SCAN_PATHS.iter().map(|s| (*s).to_owned()).collect()),
806        };
807        let cache = CacheSettings {
808            ttl_days: raw.cache.ttl_days.unwrap_or(DEFAULT_CACHE_TTL_DAYS),
809        };
810        let mut projects = Vec::with_capacity(raw.projects.len());
811        for p in raw.projects {
812            projects.push(parse_project(p, &defaults)?);
813        }
814        let self_projects: Vec<String> = projects
815            .iter()
816            .filter(|p| p.is_self())
817            .map(|p| p.name.to_string())
818            .collect();
819        if self_projects.len() > 1 {
820            return Err(ConfigError::MultipleSelfProjects {
821                projects: self_projects,
822            });
823        }
824        let project_is_self: BTreeMap<&ProjectName, bool> =
825            projects.iter().map(|p| (&p.name, p.is_self())).collect();
826        let mut series = Vec::with_capacity(raw.series.len());
827        for s in raw.series {
828            let mut pins = Vec::with_capacity(s.pins.len());
829            for pin in s.pins {
830                let spec = parse_pin(pin)?;
831                let Some(is_self) = project_is_self.get(spec.project()).copied() else {
832                    return Err(ConfigError::SeriesPinsUnknownProject {
833                        series: s.name.clone(),
834                        project: spec.project().to_string(),
835                    });
836                };
837                if spec.is_self() && !is_self {
838                    return Err(ConfigError::SelfPinReferencesExternalProject {
839                        project: spec.project().to_string(),
840                    });
841                }
842                pins.push(spec);
843            }
844            let mut untracked_projects = Vec::with_capacity(s.untracked_projects.len());
845            for name in s.untracked_projects {
846                let project = ProjectName::new(name).map_err(ConfigError::Name)?;
847                if !project_is_self.contains_key(&project) {
848                    return Err(ConfigError::SeriesPinsUnknownProject {
849                        series: s.name.clone(),
850                        project: project.to_string(),
851                    });
852                }
853                untracked_projects.push(project);
854            }
855            series.push(Series {
856                name: SeriesName::new(s.name).map_err(ConfigError::Name)?,
857                pins,
858                untracked_projects,
859                target_repo_dir_path: s.target_repo_dir_path.map(PathBuf::from),
860                target_ref: s.target_ref,
861            });
862        }
863        let config_dir = config_path.parent().unwrap_or(Path::new("."));
864        let mut hosts: Vec<Host> = Vec::with_capacity(raw.hosts.len());
865        for h in raw.hosts {
866            let host = Host::resolve(h, config_dir, &projects)?;
867            if hosts.iter().any(|existing| existing.name == host.name) {
868                return Err(HostConfigError::Duplicate {
869                    host: host.name.to_string(),
870                }
871                .into());
872            }
873            hosts.push(host);
874        }
875        let mut suite_rules = Vec::with_capacity(raw.suite_rules.len());
876        for (idx, r) in raw.suite_rules.into_iter().enumerate() {
877            suite_rules.push(parse_suite_rule(idx, r)?);
878        }
879        let path_translations = PathTranslations::from_config_stanzas(raw.path_translations)?;
880        Ok(Self {
881            config_path,
882            defaults,
883            cache,
884            projects,
885            series,
886            hosts,
887            suite_rules,
888            path_translations,
889        })
890    }
891
892    pub fn project(&self, name: &ProjectName) -> Result<&Project, ConfigError> {
893        self.projects
894            .iter()
895            .find(|p| &p.name == name)
896            .ok_or_else(|| ConfigError::UnknownProject(name.to_string()))
897    }
898
899    pub fn host(&self, name: &HostName) -> Result<&Host, ConfigError> {
900        self.hosts
901            .iter()
902            .find(|h| &h.name == name)
903            .ok_or_else(|| ConfigError::UnknownHost(name.to_string()))
904    }
905
906    pub fn series_by_name(&self, name: &SeriesName) -> Result<&Series, ConfigError> {
907        self.series
908            .iter()
909            .find(|s| &s.name == name)
910            .ok_or_else(|| ConfigError::UnknownSeries(name.to_string()))
911    }
912
913    /// Projects configured globally but not pinned by `series`, sorted alphabetically.
914    pub fn projects_missing_from_series(&self, series: &Series) -> Vec<ProjectName> {
915        let pinned: BTreeSet<&ProjectName> = series.pins.iter().map(|p| p.project()).collect();
916        let mut missing: Vec<ProjectName> = self
917            .projects
918            .iter()
919            .map(|p| &p.name)
920            .filter(|n| !pinned.contains(*n))
921            .cloned()
922            .collect();
923        missing.sort();
924        missing
925    }
926
927    /// Like [`series_by_name`](Self::series_by_name) but also emits a
928    /// `tracing::warn!` per configured project the series does not pin.
929    pub fn series_by_name_with_coverage_check(
930        &self,
931        name: &SeriesName,
932    ) -> Result<&Series, ConfigError> {
933        let series = self.series_by_name(name)?;
934        let missing = self.projects_missing_from_series(series);
935        if !missing.is_empty() {
936            let names: Vec<&str> = missing.iter().map(ProjectName::as_str).collect();
937            tracing::warn!(
938                series = %name,
939                missing_projects = ?names,
940                "series {} pins {} project(s); these configured projects have no pin: {}. Was this intentional?",
941                name, series.pins.len(), names.join(", ")
942            );
943        }
944        Ok(series)
945    }
946
947    pub fn snapshot_dir(&self) -> PathBuf {
948        let dir = &self.defaults.snapshot_dir;
949        if dir.is_absolute() {
950            dir.clone()
951        } else {
952            self.config_path
953                .parent()
954                .map(|p| p.join(dir))
955                .unwrap_or_else(|| dir.clone())
956        }
957    }
958}
959
960/// Parse a standalone TOML file of extra suite rules: the same
961/// `[[suite_rule]]` shape the config accepts, validated identically so
962/// the trailing-slash and unknown-placeholder mistakes fail fast.
963pub fn parse_suite_rules_toml(text: &str) -> Result<Vec<ExtraRule>, ConfigError> {
964    #[derive(serde::Deserialize)]
965    #[serde(deny_unknown_fields)]
966    struct RulesDoc {
967        #[serde(default)]
968        suite_rule: Vec<SuiteRuleRaw>,
969    }
970    let doc: RulesDoc = toml::from_str(text)?;
971    doc.suite_rule
972        .into_iter()
973        .enumerate()
974        .map(|(idx, raw)| parse_suite_rule(idx, raw))
975        .collect()
976}
977
978fn parse_suite_rule(idx: usize, raw: SuiteRuleRaw) -> Result<ExtraRule, ConfigError> {
979    let pattern = raw.when_modified_path_matches;
980    let compiled = Regex::new(&pattern).map_err(|e| ConfigError::SuiteRuleRegex {
981        rule_index: idx,
982        detail: e.to_string(),
983    })?;
984    let path_capture_names: Vec<String> = compiled
985        .capture_names()
986        .flatten()
987        .map(|s| s.to_owned())
988        .collect();
989    let line_match = match raw.when_modified_line_matches {
990        Some(line_pattern) => {
991            let line_compiled =
992                Regex::new(&line_pattern).map_err(|e| ConfigError::SuiteRuleRegex {
993                    rule_index: idx,
994                    detail: e.to_string(),
995                })?;
996            let line_captures: Vec<String> = line_compiled
997                .capture_names()
998                .flatten()
999                .map(|s| s.to_owned())
1000                .collect();
1001            Some(LineMatch {
1002                pattern: line_pattern,
1003                captures: line_captures,
1004            })
1005        }
1006        None => None,
1007    };
1008    let mut allowed_captures: Vec<String> = path_capture_names.clone();
1009    if let Some(lm) = &line_match {
1010        allowed_captures.extend(lm.captures.iter().cloned());
1011    }
1012    let mut templates: Vec<String> = Vec::new();
1013    templates.extend(raw.include_suite);
1014    templates.extend(raw.also);
1015    for t in &templates {
1016        if let Err(e) = validate_template_placeholders(t, &allowed_captures) {
1017            return Err(ConfigError::SuiteRuleUnknownPlaceholder {
1018                rule_index: idx,
1019                placeholder: e.placeholder,
1020            });
1021        }
1022    }
1023    if raw.include_suite_for_dep_modules && !allowed_captures.iter().any(|c| c == "dep") {
1024        return Err(ConfigError::SuiteRuleMissingDepCapture { rule_index: idx });
1025    }
1026    let name = raw.name.unwrap_or_else(|| format!("suite_rule_{idx}"));
1027    Ok(ExtraRule {
1028        name,
1029        trigger: ExtraRuleTrigger::PathRegex {
1030            pattern,
1031            captures: path_capture_names,
1032        },
1033        include_suites: Vec::new(),
1034        include_suite_templates: templates,
1035        line_match,
1036        include_suite_for_dep_modules: raw.include_suite_for_dep_modules,
1037    })
1038}
1039
1040fn module_names(raw: Option<Vec<String>>) -> Result<Vec<ModuleName>, ConfigError> {
1041    raw.unwrap_or_default()
1042        .into_iter()
1043        .map(|m| ModuleName::new(m).map_err(ConfigError::Name))
1044        .collect()
1045}
1046
1047fn parse_project(p: ProjectRaw, defaults: &Defaults) -> Result<Project, ConfigError> {
1048    let layout = p
1049        .layout
1050        .as_deref()
1051        .map(ProjectLayout::from_str)
1052        .transpose()?
1053        .unwrap_or(ProjectLayout::SingleApp);
1054    let language = p
1055        .language
1056        .as_deref()
1057        .map(Language::from_str)
1058        .transpose()?
1059        .unwrap_or(Language::Erlang);
1060    let layout_defaults = layout.defaults();
1061    let scan_paths = p.scan_paths.unwrap_or_else(|| match layout {
1062        ProjectLayout::SingleApp => defaults.scan_paths.clone(),
1063        ProjectLayout::MultiApp | ProjectLayout::ErlangOtp => Vec::new(),
1064    });
1065    let app_roots = p
1066        .app_roots
1067        .unwrap_or_else(|| layout_defaults.app_roots.clone());
1068    let include_apps = match p.include_apps {
1069        Some(v) => parse_app_names(v)?,
1070        None => layout_defaults.include_apps.clone(),
1071    };
1072    let exclude_apps = match p.exclude_apps {
1073        Some(v) => parse_app_names(v)?,
1074        None => layout_defaults.exclude_apps.clone(),
1075    };
1076    let excluded_subdirs = p
1077        .excluded_subdirs
1078        .unwrap_or_else(|| layout_defaults.excluded_subdirs.clone());
1079    let tag_pattern = match p.tag_pattern {
1080        Some(s) => Some(TagGlob::new(s).map_err(ConfigError::Name)?),
1081        None => layout_defaults.tag_pattern.clone(),
1082    };
1083    let min_tag = match p.min_tag {
1084        Some(s) => Some(TagName::new(s).map_err(ConfigError::Name)?),
1085        None => layout_defaults.min_tag.clone(),
1086    };
1087    let exclude_tag_markers = p.exclude_tag_markers.unwrap_or_else(|| {
1088        DEFAULT_EXCLUDE_TAG_MARKERS
1089            .iter()
1090            .map(|s| (*s).to_owned())
1091            .collect()
1092    });
1093    let manifest = p
1094        .manifest
1095        .as_deref()
1096        .map(ManifestSource::from_str)
1097        .transpose()?
1098        .unwrap_or_default();
1099    let name = ProjectName::new(p.name).map_err(ConfigError::Name)?;
1100    if matches!(layout, ProjectLayout::MultiApp | ProjectLayout::ErlangOtp) && app_roots.is_empty()
1101    {
1102        return Err(ConfigError::LayoutWithoutAppRoots {
1103            project: name.to_string(),
1104            layout: layout.label().to_owned(),
1105        });
1106    }
1107    let kind = p
1108        .kind
1109        .as_deref()
1110        .map(ProjectKind::from_str)
1111        .transpose()?
1112        .unwrap_or(ProjectKind::External);
1113    let family = p
1114        .family
1115        .as_deref()
1116        .map(ProjectFamily::from_str)
1117        .transpose()?
1118        .unwrap_or_default();
1119    let source = match (kind, p.git_url) {
1120        (ProjectKind::External, Some(g)) => ProjectSource::External {
1121            git_url: PathBuf::from(g),
1122        },
1123        (ProjectKind::External, None) => {
1124            return Err(ConfigError::ExternalProjectMissingGitUrl(name.to_string()));
1125        }
1126        (ProjectKind::SelfRepo, None) => ProjectSource::SelfRepo,
1127        (ProjectKind::SelfRepo, Some(_)) => {
1128            return Err(ConfigError::SelfProjectHasGitUrl(name.to_string()));
1129        }
1130    };
1131    Ok(Project {
1132        name,
1133        source,
1134        family,
1135        language,
1136        tag_prefix: p
1137            .tag_prefix
1138            .or(layout_defaults.tag_prefix)
1139            .unwrap_or_else(|| String::from("v")),
1140        public_modules: module_names(p.public_modules)?,
1141        internal_modules: module_names(p.internal_modules)?,
1142        layout,
1143        scan_paths,
1144        app_roots,
1145        include_apps,
1146        exclude_apps,
1147        excluded_subdirs,
1148        tag_pattern,
1149        min_tag,
1150        exclude_tag_markers,
1151        manifest,
1152    })
1153}
1154
1155fn parse_app_names(raw: Vec<String>) -> Result<Vec<ApplicationName>, ConfigError> {
1156    raw.into_iter()
1157        .map(|s| ApplicationName::new(s).map_err(ConfigError::Name))
1158        .collect()
1159}
1160
1161fn parse_pin(pin: PinRaw) -> Result<PinSpec, ConfigError> {
1162    match pin {
1163        PinRaw::SelfBranch {
1164            project,
1165            branch,
1166            repo_dir_path,
1167        } => {
1168            let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1169            let git_ref = GitRef::new(branch).map_err(ConfigError::Name)?;
1170            Ok(PinSpec::SelfRef {
1171                project,
1172                git_ref,
1173                repo_dir_path,
1174            })
1175        }
1176        PinRaw::SelfSha {
1177            project,
1178            sha,
1179            repo_dir_path,
1180        } => {
1181            let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1182            let git_ref = GitRef::new(sha).map_err(ConfigError::Name)?;
1183            Ok(PinSpec::SelfRef {
1184                project,
1185                git_ref,
1186                repo_dir_path,
1187            })
1188        }
1189        PinRaw::Literal { project, tag } => {
1190            let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1191            let tag = TagName::new(tag).map_err(ConfigError::Name)?;
1192            Ok(PinSpec::literal(project, tag))
1193        }
1194        PinRaw::Pattern {
1195            project,
1196            tag_pattern,
1197            select,
1198        } => {
1199            let project = ProjectName::new(project).map_err(ConfigError::Name)?;
1200            let pattern = TagGlob::new(tag_pattern).map_err(ConfigError::Name)?;
1201            let select = match select.as_str() {
1202                "latest" => PinSelect::Latest,
1203                "oldest" => PinSelect::Oldest,
1204                other => return Err(ConfigError::PinUnknownSelect(other.to_owned())),
1205            };
1206            Ok(PinSpec::pattern(project, pattern, select))
1207        }
1208    }
1209}