mod host;
mod path_translation;
pub use host::{
ERLANG_OTP_TAG_PREFIX, ErlangSupport, ErlangSupportRaw, Host, HostConfigError, HostPinsRaw,
HostRaw, MakefileVariableRaw, PinSource, PinSourceKind, ReleaseRootsRaw,
};
pub use path_translation::{
PathTranslation, PathTranslations, TranslationDirection, TranslationOrigin,
};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::LazyLock;
use regex::Regex;
use serde::{Deserialize, Deserializer, Serialize};
use crate::errors::{ConfigError, NameError};
use crate::model::app_manifest::ManifestSource;
use crate::model::names::{
ApplicationName, BehaviourName, FunctionName, GitRef, HostName, MacroName, ModuleName,
ProjectName, SeriesName, TagGlob, TagName, TypeName, vocabulary,
};
use crate::model::pin::{self, Pin, PinSelect, PinSpec};
use crate::store::{SnapshotStore, StoreMode};
use crate::suites::rules::validate_template_placeholders;
use crate::suites::{ExtraRule, ExtraRuleTrigger, LineMatch};
pub const CONFIG_VERSION: u32 = 1;
const DEFAULT_SCAN_PATHS: &[&str] = &["src/**/*.erl", "src/**/*.ex", "include/**/*.hrl"];
const DEFAULT_SNAPSHOT_DIR: &str = "snapshots";
const DEFAULT_FALLBACK_BRANCH: &str = "main";
const ERLANG_OTP_APP_ROOTS: &[&str] = &["lib/*", "erts/preloaded"];
const ERLANG_OTP_EXCLUDE_APPS: &[&str] = &[
"odbc",
"snmp",
"ssh",
"tftp",
"ftp",
"wx",
"megaco",
"edoc",
"jinterface",
"diameter",
];
const ERLANG_OTP_EXCLUDED_SUBDIRS: &[&str] = &["doc", "example", "examples", "test"];
const ERLANG_OTP_TAG_PATTERN: &str = "OTP-*";
const ERLANG_OTP_MIN_TAG: &str = "OTP-26.0";
const DEFAULT_EXCLUDE_TAG_MARKERS: &[&str] = &["-rc", "-alpha", "-beta", "-pre"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
#[serde(default = "default_config_version")]
pub config_version: u32,
#[serde(default)]
pub defaults: DefaultsRaw,
#[serde(default)]
pub cache: CacheRaw,
#[serde(default, rename = "project")]
pub projects: Vec<ProjectRaw>,
#[serde(default, rename = "series")]
pub series: Vec<SeriesRaw>,
#[serde(default, rename = "host")]
pub hosts: Vec<HostRaw>,
#[serde(default, rename = "suite_rule")]
pub suite_rules: Vec<SuiteRuleRaw>,
#[serde(default, rename = "path_translation")]
pub path_translations: Vec<PathTranslationRaw>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PathTranslationRaw {
pub name: String,
#[serde(default = "default_translation_direction")]
pub direction: String,
pub source_prefix: String,
pub target_prefix: String,
}
fn default_translation_direction() -> String {
"source_to_target".to_owned()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SuiteRuleRaw {
pub name: Option<String>,
pub when_modified_path_matches: String,
#[serde(default)]
pub when_modified_line_matches: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub include_suite: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string_or_vec")]
pub also: Vec<String>,
#[serde(default)]
pub include_suite_for_dep_modules: bool,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrVec {
One(String),
Many(Vec<String>),
}
fn deserialize_string_or_vec<'de, D>(d: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
Option::<StringOrVec>::deserialize(d).map(|opt| match opt {
None => Vec::new(),
Some(StringOrVec::One(s)) => vec![s],
Some(StringOrVec::Many(v)) => v,
})
}
fn default_config_version() -> u32 {
CONFIG_VERSION
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DefaultsRaw {
pub snapshot_dir: Option<String>,
pub fallback_branch: Option<String>,
pub scan_paths: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CacheRaw {
pub ttl_days: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct CacheSettings {
pub ttl_days: u32,
}
pub const DEFAULT_CACHE_TTL_DAYS: u32 = 42;
impl Default for CacheSettings {
fn default() -> Self {
Self {
ttl_days: DEFAULT_CACHE_TTL_DAYS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectRaw {
pub name: String,
pub git_url: Option<String>,
pub kind: Option<String>,
pub family: Option<String>,
pub language: Option<String>,
pub tag_prefix: Option<String>,
pub public_modules: Option<Vec<String>>,
pub internal_modules: Option<Vec<String>>,
pub scan_paths: Option<Vec<String>>,
pub layout: Option<String>,
pub app_roots: Option<Vec<String>>,
pub include_apps: Option<Vec<String>>,
pub exclude_apps: Option<Vec<String>>,
pub excluded_subdirs: Option<Vec<String>>,
pub tag_pattern: Option<String>,
#[serde(alias = "oldest_tag")]
pub min_tag: Option<String>,
pub exclude_tag_markers: Option<Vec<String>>,
pub manifest: Option<String>,
}
vocabulary!(
pub enum ProjectKind: "project kind" {
External => "external",
SelfRepo => "self",
}
);
vocabulary!(
#[derive(Default)]
pub enum ProjectFamily: "project family" {
#[default]
Generic => "generic",
ErlangOtp => "erlang_otp",
Ra => "ra",
Osiris => "osiris",
Khepri => "khepri",
Rabbitmq => "rabbitmq",
Cowboy => "cowboy",
}
);
impl ProjectFamily {
pub fn defaults(self) -> FamilyDefaults {
match self {
Self::Generic | Self::ErlangOtp => FamilyDefaults::default(),
Self::Ra => RA_DEFAULTS.clone(),
Self::Osiris => OSIRIS_DEFAULTS.clone(),
Self::Khepri => KHEPRI_DEFAULTS.clone(),
Self::Rabbitmq => RABBITMQ_DEFAULTS.clone(),
Self::Cowboy => COWBOY_DEFAULTS.clone(),
}
}
}
const RA_WIRE_CONSTANTS: &[(&str, &[&str])] = &[
("ra_log_segment", &["VERSION", "MAGIC"]),
("ra_log_wal", &["CURRENT_VERSION", "MAGIC"]),
("ra_log_snapshot", &["VERSION", "MAGIC"]),
("ra_snapshot", &["IDX_VERSION", "IDX_MAGIC"]),
(
"ra",
&[
"RA_PROTO_VERSION",
"C_RA_LOG_WRITE_OPS",
"C_RA_LOG_WRITE_RESENDS",
"C_RA_LOG_READ_OPS",
"C_RA_LOG_READ_CACHE",
"C_RA_LOG_READ_MEM_TBL",
"C_RA_LOG_READ_CLOSED_MEM_TBL",
"C_RA_LOG_READ_SEGMENT",
"C_RA_LOG_FETCH_TERM",
"C_RA_LOG_SNAPSHOTS_WRITTEN",
"C_RA_LOG_SNAPSHOTS_INSTALLED",
"C_RA_LOG_SNAPSHOT_BYTES_WRITTEN",
"C_RA_LOG_OPEN_SEGMENTS",
"C_RA_LOG_CHECKPOINTS_WRITTEN",
"C_RA_LOG_CHECKPOINT_BYTES_WRITTEN",
"C_RA_LOG_CHECKPOINTS_PROMOTED",
"C_RA_LOG_COMPACTIONS_MINOR_COUNT",
"C_RA_LOG_COMPACTIONS_MAJOR_COUNT",
"C_RA_LOG_COMPACTIONS_SEGMENTS_WRITTEN",
"C_RA_LOG_COMPACTIONS_SEGMENTS_COMPACTED",
"C_RA_LOG_RESERVED",
],
),
];
const RA_VERSIONED_MACHINES: &[&str] = &["ra_machine"];
const RA_DEP_BEHAVIOURS: &[&str] = &["ra_machine", "ra_snapshot"];
const OSIRIS_WIRE_CONSTANTS: &[(&str, &[&str])] = &[(
"osiris",
&[
"MAGIC",
"VERSION",
"IDX_VERSION",
"LOG_VERSION",
"IDX_HEADER",
"LOG_HEADER",
],
)];
const KHEPRI_WIRE_CONSTANTS: &[(&str, &[&str])] = &[(
"khepri_node",
&["INIT_DATA_VERSION", "INIT_CHILD_LIST_VERSION"],
)];
const RABBITMQ_VERSIONED_MACHINE_IMPLS: &[(&str, &str, bool)] = &[
("rabbit_fifo", "version", true),
("rabbit_stream_coordinator", "version", false),
];
fn build_wire_constants(rows: &[(&str, &[&str])]) -> Result<Vec<WireConstantDecl>, NameError> {
rows.iter()
.map(|(module, macros)| {
Ok(WireConstantDecl {
module: ModuleName::from_str(module)?,
macros: macros
.iter()
.map(|m| MacroName::from_str(m))
.collect::<Result<_, _>>()?,
})
})
.collect()
}
fn build_versioned_machines(behaviours: &[&str]) -> Result<Vec<VersionedMachineDecl>, NameError> {
behaviours
.iter()
.map(|b| {
Ok(VersionedMachineDecl {
behaviour: BehaviourName::from_str(b)?,
})
})
.collect()
}
fn build_versioned_machine_impls(
rows: &[(&str, &str, bool)],
) -> Result<Vec<VersionedMachineImplDecl>, NameError> {
rows.iter()
.map(|(module, version_function, allow_state_flag_gating)| {
Ok(VersionedMachineImplDecl {
module: ModuleName::from_str(module)?,
version_function: FunctionName::from_str(version_function)?,
allow_state_flag_gating: *allow_state_flag_gating,
})
})
.collect()
}
const RA_OPTION_TYPES: &[(&str, &str)] =
&[("ra_system", "config"), ("ra_server", "ra_server_config")];
const COWBOY_OPTION_TYPES: &[(&str, &str)] = &[
("cowboy_http", "opts"),
("cowboy_http2", "opts"),
("cowboy_websocket", "opts"),
];
const RABBITMQ_SUITE_REGISTRATION_MARKER: &str = "PARALLEL_CT";
fn build_option_types(rows: &[(&str, &str)]) -> Result<Vec<OptionTypeDecl>, NameError> {
rows.iter()
.map(|(module, type_name)| OptionTypeDecl::new(module, type_name))
.collect()
}
fn build_ra_defaults() -> Result<FamilyDefaults, NameError> {
Ok(FamilyDefaults {
wire_constants: build_wire_constants(RA_WIRE_CONSTANTS)?,
versioned_machines: build_versioned_machines(RA_VERSIONED_MACHINES)?,
versioned_machine_impls: Vec::new(),
test_helper_search_paths: Vec::new(),
dep_behaviours: RA_DEP_BEHAVIOURS.iter().map(|s| (*s).to_owned()).collect(),
option_types: build_option_types(RA_OPTION_TYPES)?,
suite_registration: None,
})
}
fn build_osiris_defaults() -> Result<FamilyDefaults, NameError> {
Ok(FamilyDefaults {
wire_constants: build_wire_constants(OSIRIS_WIRE_CONSTANTS)?,
..Default::default()
})
}
fn build_khepri_defaults() -> Result<FamilyDefaults, NameError> {
Ok(FamilyDefaults {
wire_constants: build_wire_constants(KHEPRI_WIRE_CONSTANTS)?,
..Default::default()
})
}
fn build_rabbitmq_defaults() -> Result<FamilyDefaults, NameError> {
Ok(FamilyDefaults {
wire_constants: Vec::new(),
versioned_machines: Vec::new(),
versioned_machine_impls: build_versioned_machine_impls(RABBITMQ_VERSIONED_MACHINE_IMPLS)?,
test_helper_search_paths: rabbitmq_default_test_helper_search_paths(),
dep_behaviours: Vec::new(),
option_types: Vec::new(),
suite_registration: Some(SuiteRegistrationDecl {
marker: RABBITMQ_SUITE_REGISTRATION_MARKER.to_owned(),
}),
})
}
fn build_cowboy_defaults() -> Result<FamilyDefaults, NameError> {
Ok(FamilyDefaults {
option_types: build_option_types(COWBOY_OPTION_TYPES)?,
..Default::default()
})
}
static RA_DEFAULTS: LazyLock<FamilyDefaults> =
LazyLock::new(|| build_ra_defaults().expect("ra family default is valid"));
static OSIRIS_DEFAULTS: LazyLock<FamilyDefaults> =
LazyLock::new(|| build_osiris_defaults().expect("osiris family default is valid"));
static KHEPRI_DEFAULTS: LazyLock<FamilyDefaults> =
LazyLock::new(|| build_khepri_defaults().expect("khepri family default is valid"));
static RABBITMQ_DEFAULTS: LazyLock<FamilyDefaults> =
LazyLock::new(|| build_rabbitmq_defaults().expect("rabbitmq family default is valid"));
static COWBOY_DEFAULTS: LazyLock<FamilyDefaults> =
LazyLock::new(|| build_cowboy_defaults().expect("cowboy family default is valid"));
fn rabbitmq_default_test_helper_search_paths() -> Vec<String> {
vec![
"deps/*/test".to_owned(),
"deps/*/src".to_owned(),
"deps/rabbitmq_ct_helpers/src".to_owned(),
]
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FamilyDefaults {
pub wire_constants: Vec<WireConstantDecl>,
pub versioned_machines: Vec<VersionedMachineDecl>,
pub versioned_machine_impls: Vec<VersionedMachineImplDecl>,
pub test_helper_search_paths: Vec<String>,
pub dep_behaviours: Vec<String>,
pub option_types: Vec<OptionTypeDecl>,
pub suite_registration: Option<SuiteRegistrationDecl>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptionTypeDecl {
pub module: ModuleName,
pub type_name: TypeName,
}
impl OptionTypeDecl {
pub fn new(module: &str, type_name: &str) -> Result<Self, NameError> {
Ok(Self {
module: ModuleName::from_str(module)?,
type_name: TypeName::from_str(type_name)?,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SuiteRegistrationDecl {
pub marker: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WireConstantDecl {
pub module: ModuleName,
pub macros: Vec<MacroName>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionedMachineDecl {
pub behaviour: BehaviourName,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionedMachineImplDecl {
pub module: ModuleName,
pub version_function: FunctionName,
pub allow_state_flag_gating: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SeriesRaw {
pub name: String,
pub pins: Vec<PinRaw>,
#[serde(default)]
pub untracked_projects: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_repo_dir_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PinRaw {
Literal {
project: String,
tag: String,
},
Pattern {
project: String,
tag_pattern: String,
select: String,
},
SelfBranch {
project: String,
branch: String,
#[serde(default)]
repo_dir_path: Option<PathBuf>,
},
SelfSha {
project: String,
sha: String,
#[serde(default)]
repo_dir_path: Option<PathBuf>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
pub struct Defaults {
pub snapshot_dir: PathBuf,
pub fallback_branch: String,
pub scan_paths: Vec<String>,
}
vocabulary!(
pub enum Language: "language" {
Erlang => "erlang",
Elixir => "elixir",
}
);
vocabulary!(
pub enum ProjectLayout: "project layout" {
SingleApp => "single_app",
MultiApp => "multi_app",
ErlangOtp => "erlang_otp",
}
);
impl ProjectLayout {
pub fn defaults(self) -> LayoutDefaults {
match self {
Self::SingleApp | Self::MultiApp => LayoutDefaults::default(),
Self::ErlangOtp => LayoutDefaults {
app_roots: ERLANG_OTP_APP_ROOTS
.iter()
.map(|s| (*s).to_owned())
.collect(),
include_apps: Vec::new(),
exclude_apps: ERLANG_OTP_EXCLUDE_APPS
.iter()
.map(|s| ApplicationName::new(*s).expect("static name"))
.collect(),
excluded_subdirs: ERLANG_OTP_EXCLUDED_SUBDIRS
.iter()
.map(|s| (*s).to_owned())
.collect(),
tag_pattern: Some(TagGlob::new(ERLANG_OTP_TAG_PATTERN).expect("static tag glob")),
min_tag: Some(TagName::new(ERLANG_OTP_MIN_TAG).expect("static tag")),
tag_prefix: Some(ERLANG_OTP_TAG_PREFIX.to_owned()),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct LayoutDefaults {
pub app_roots: Vec<String>,
pub include_apps: Vec<ApplicationName>,
pub exclude_apps: Vec<ApplicationName>,
pub excluded_subdirs: Vec<String>,
pub tag_pattern: Option<TagGlob>,
pub min_tag: Option<TagName>,
pub tag_prefix: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProjectSource {
External {
git_url: PathBuf,
},
#[serde(rename = "self")]
SelfRepo,
}
impl ProjectSource {
#[must_use]
pub fn kind(&self) -> ProjectKind {
match self {
Self::External { .. } => ProjectKind::External,
Self::SelfRepo => ProjectKind::SelfRepo,
}
}
#[must_use]
pub fn git_url(&self) -> Option<&Path> {
match self {
Self::External { git_url } => Some(git_url),
Self::SelfRepo => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Project {
pub name: ProjectName,
#[serde(flatten)]
pub source: ProjectSource,
pub family: ProjectFamily,
pub language: Language,
pub tag_prefix: String,
pub public_modules: Vec<ModuleName>,
pub internal_modules: Vec<ModuleName>,
pub layout: ProjectLayout,
pub scan_paths: Vec<String>,
pub app_roots: Vec<String>,
pub include_apps: Vec<ApplicationName>,
pub exclude_apps: Vec<ApplicationName>,
pub excluded_subdirs: Vec<String>,
pub tag_pattern: Option<TagGlob>,
pub min_tag: Option<TagName>,
pub exclude_tag_markers: Vec<String>,
pub manifest: ManifestSource,
}
impl Project {
pub fn is_prerelease_tag(&self, tag: &TagName) -> bool {
self.exclude_tag_markers
.iter()
.any(|marker| tag.as_str().contains(marker))
}
pub fn is_self(&self) -> bool {
matches!(self.source, ProjectSource::SelfRepo)
}
pub fn owns_path(&self, path: &Path) -> bool {
let candidate = path.to_string_lossy();
self.scan_paths
.iter()
.chain(self.app_roots.iter())
.any(|raw| path_under_glob_prefix(&candidate, raw))
}
}
fn path_under_glob_prefix(candidate: &str, raw: &str) -> bool {
let prefix = glob_directory_prefix(raw).trim_end_matches('/');
if prefix.is_empty() {
return false;
}
if !candidate.starts_with(prefix) {
return false;
}
let rest = &candidate[prefix.len()..];
rest.is_empty() || rest.starts_with('/')
}
fn glob_directory_prefix(pattern: &str) -> &str {
let metachars = ['*', '?', '[', '{'];
match pattern.find(metachars) {
Some(i) => {
let head = &pattern[..i];
match head.rfind('/') {
Some(slash) => &head[..=slash],
None => "",
}
}
None => pattern,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Series {
pub name: SeriesName,
pub pins: Vec<PinSpec>,
#[serde(default)]
pub untracked_projects: Vec<ProjectName>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_repo_dir_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_ref: Option<String>,
}
impl Series {
pub fn resolve_pins<M: StoreMode>(
&self,
store: &SnapshotStore<M>,
) -> Result<Vec<Pin>, ConfigError> {
pin::resolve_all(&self.pins, store)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
pub struct Config {
pub config_path: PathBuf,
pub defaults: Defaults,
#[serde(default)]
pub cache: CacheSettings,
pub projects: Vec<Project>,
pub series: Vec<Series>,
pub hosts: Vec<Host>,
pub suite_rules: Vec<ExtraRule>,
pub path_translations: PathTranslations,
}
impl Config {
pub fn self_project(&self) -> Option<&Project> {
self.projects.iter().find(|p| p.is_self())
}
pub fn load(path: &Path) -> Result<Self, ConfigError> {
if !path.exists() {
return Err(ConfigError::NotFound(path.to_path_buf()));
}
let text = fs::read_to_string(path)?;
let raw: ConfigFile = toml::from_str(&text)?;
Self::from_raw(path.to_path_buf(), raw)
}
pub fn from_raw(config_path: PathBuf, raw: ConfigFile) -> Result<Self, ConfigError> {
if raw.config_version != CONFIG_VERSION {
return Err(ConfigError::UnknownConfigVersion(raw.config_version));
}
let defaults = Defaults {
snapshot_dir: raw
.defaults
.snapshot_dir
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_SNAPSHOT_DIR)),
fallback_branch: raw
.defaults
.fallback_branch
.unwrap_or_else(|| DEFAULT_FALLBACK_BRANCH.to_owned()),
scan_paths: raw
.defaults
.scan_paths
.unwrap_or_else(|| DEFAULT_SCAN_PATHS.iter().map(|s| (*s).to_owned()).collect()),
};
let cache = CacheSettings {
ttl_days: raw.cache.ttl_days.unwrap_or(DEFAULT_CACHE_TTL_DAYS),
};
let mut projects = Vec::with_capacity(raw.projects.len());
for p in raw.projects {
projects.push(parse_project(p, &defaults)?);
}
let self_projects: Vec<String> = projects
.iter()
.filter(|p| p.is_self())
.map(|p| p.name.to_string())
.collect();
if self_projects.len() > 1 {
return Err(ConfigError::MultipleSelfProjects {
projects: self_projects,
});
}
let project_is_self: BTreeMap<&ProjectName, bool> =
projects.iter().map(|p| (&p.name, p.is_self())).collect();
let mut series = Vec::with_capacity(raw.series.len());
for s in raw.series {
let mut pins = Vec::with_capacity(s.pins.len());
for pin in s.pins {
let spec = parse_pin(pin)?;
let Some(is_self) = project_is_self.get(spec.project()).copied() else {
return Err(ConfigError::SeriesPinsUnknownProject {
series: s.name.clone(),
project: spec.project().to_string(),
});
};
if spec.is_self() && !is_self {
return Err(ConfigError::SelfPinReferencesExternalProject {
project: spec.project().to_string(),
});
}
pins.push(spec);
}
let mut untracked_projects = Vec::with_capacity(s.untracked_projects.len());
for name in s.untracked_projects {
let project = ProjectName::new(name).map_err(ConfigError::Name)?;
if !project_is_self.contains_key(&project) {
return Err(ConfigError::SeriesPinsUnknownProject {
series: s.name.clone(),
project: project.to_string(),
});
}
untracked_projects.push(project);
}
series.push(Series {
name: SeriesName::new(s.name).map_err(ConfigError::Name)?,
pins,
untracked_projects,
target_repo_dir_path: s.target_repo_dir_path.map(PathBuf::from),
target_ref: s.target_ref,
});
}
let config_dir = config_path.parent().unwrap_or(Path::new("."));
let mut hosts: Vec<Host> = Vec::with_capacity(raw.hosts.len());
for h in raw.hosts {
let host = Host::resolve(h, config_dir, &projects)?;
if hosts.iter().any(|existing| existing.name == host.name) {
return Err(HostConfigError::Duplicate {
host: host.name.to_string(),
}
.into());
}
hosts.push(host);
}
let mut suite_rules = Vec::with_capacity(raw.suite_rules.len());
for (idx, r) in raw.suite_rules.into_iter().enumerate() {
suite_rules.push(parse_suite_rule(idx, r)?);
}
let path_translations = PathTranslations::from_config_stanzas(raw.path_translations)?;
Ok(Self {
config_path,
defaults,
cache,
projects,
series,
hosts,
suite_rules,
path_translations,
})
}
pub fn project(&self, name: &ProjectName) -> Result<&Project, ConfigError> {
self.projects
.iter()
.find(|p| &p.name == name)
.ok_or_else(|| ConfigError::UnknownProject(name.to_string()))
}
pub fn host(&self, name: &HostName) -> Result<&Host, ConfigError> {
self.hosts
.iter()
.find(|h| &h.name == name)
.ok_or_else(|| ConfigError::UnknownHost(name.to_string()))
}
pub fn series_by_name(&self, name: &SeriesName) -> Result<&Series, ConfigError> {
self.series
.iter()
.find(|s| &s.name == name)
.ok_or_else(|| ConfigError::UnknownSeries(name.to_string()))
}
pub fn projects_missing_from_series(&self, series: &Series) -> Vec<ProjectName> {
let pinned: BTreeSet<&ProjectName> = series.pins.iter().map(|p| p.project()).collect();
let mut missing: Vec<ProjectName> = self
.projects
.iter()
.map(|p| &p.name)
.filter(|n| !pinned.contains(*n))
.cloned()
.collect();
missing.sort();
missing
}
pub fn series_by_name_with_coverage_check(
&self,
name: &SeriesName,
) -> Result<&Series, ConfigError> {
let series = self.series_by_name(name)?;
let missing = self.projects_missing_from_series(series);
if !missing.is_empty() {
let names: Vec<&str> = missing.iter().map(ProjectName::as_str).collect();
tracing::warn!(
series = %name,
missing_projects = ?names,
"series {} pins {} project(s); these configured projects have no pin: {}. Was this intentional?",
name, series.pins.len(), names.join(", ")
);
}
Ok(series)
}
pub fn snapshot_dir(&self) -> PathBuf {
let dir = &self.defaults.snapshot_dir;
if dir.is_absolute() {
dir.clone()
} else {
self.config_path
.parent()
.map(|p| p.join(dir))
.unwrap_or_else(|| dir.clone())
}
}
}
pub fn parse_suite_rules_toml(text: &str) -> Result<Vec<ExtraRule>, ConfigError> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RulesDoc {
#[serde(default)]
suite_rule: Vec<SuiteRuleRaw>,
}
let doc: RulesDoc = toml::from_str(text)?;
doc.suite_rule
.into_iter()
.enumerate()
.map(|(idx, raw)| parse_suite_rule(idx, raw))
.collect()
}
fn parse_suite_rule(idx: usize, raw: SuiteRuleRaw) -> Result<ExtraRule, ConfigError> {
let pattern = raw.when_modified_path_matches;
let compiled = Regex::new(&pattern).map_err(|e| ConfigError::SuiteRuleRegex {
rule_index: idx,
detail: e.to_string(),
})?;
let path_capture_names: Vec<String> = compiled
.capture_names()
.flatten()
.map(|s| s.to_owned())
.collect();
let line_match = match raw.when_modified_line_matches {
Some(line_pattern) => {
let line_compiled =
Regex::new(&line_pattern).map_err(|e| ConfigError::SuiteRuleRegex {
rule_index: idx,
detail: e.to_string(),
})?;
let line_captures: Vec<String> = line_compiled
.capture_names()
.flatten()
.map(|s| s.to_owned())
.collect();
Some(LineMatch {
pattern: line_pattern,
captures: line_captures,
})
}
None => None,
};
let mut allowed_captures: Vec<String> = path_capture_names.clone();
if let Some(lm) = &line_match {
allowed_captures.extend(lm.captures.iter().cloned());
}
let mut templates: Vec<String> = Vec::new();
templates.extend(raw.include_suite);
templates.extend(raw.also);
for t in &templates {
if let Err(e) = validate_template_placeholders(t, &allowed_captures) {
return Err(ConfigError::SuiteRuleUnknownPlaceholder {
rule_index: idx,
placeholder: e.placeholder,
});
}
}
if raw.include_suite_for_dep_modules && !allowed_captures.iter().any(|c| c == "dep") {
return Err(ConfigError::SuiteRuleMissingDepCapture { rule_index: idx });
}
let name = raw.name.unwrap_or_else(|| format!("suite_rule_{idx}"));
Ok(ExtraRule {
name,
trigger: ExtraRuleTrigger::PathRegex {
pattern,
captures: path_capture_names,
},
include_suites: Vec::new(),
include_suite_templates: templates,
line_match,
include_suite_for_dep_modules: raw.include_suite_for_dep_modules,
})
}
fn module_names(raw: Option<Vec<String>>) -> Result<Vec<ModuleName>, ConfigError> {
raw.unwrap_or_default()
.into_iter()
.map(|m| ModuleName::new(m).map_err(ConfigError::Name))
.collect()
}
fn parse_project(p: ProjectRaw, defaults: &Defaults) -> Result<Project, ConfigError> {
let layout = p
.layout
.as_deref()
.map(ProjectLayout::from_str)
.transpose()?
.unwrap_or(ProjectLayout::SingleApp);
let language = p
.language
.as_deref()
.map(Language::from_str)
.transpose()?
.unwrap_or(Language::Erlang);
let layout_defaults = layout.defaults();
let scan_paths = p.scan_paths.unwrap_or_else(|| match layout {
ProjectLayout::SingleApp => defaults.scan_paths.clone(),
ProjectLayout::MultiApp | ProjectLayout::ErlangOtp => Vec::new(),
});
let app_roots = p
.app_roots
.unwrap_or_else(|| layout_defaults.app_roots.clone());
let include_apps = match p.include_apps {
Some(v) => parse_app_names(v)?,
None => layout_defaults.include_apps.clone(),
};
let exclude_apps = match p.exclude_apps {
Some(v) => parse_app_names(v)?,
None => layout_defaults.exclude_apps.clone(),
};
let excluded_subdirs = p
.excluded_subdirs
.unwrap_or_else(|| layout_defaults.excluded_subdirs.clone());
let tag_pattern = match p.tag_pattern {
Some(s) => Some(TagGlob::new(s).map_err(ConfigError::Name)?),
None => layout_defaults.tag_pattern.clone(),
};
let min_tag = match p.min_tag {
Some(s) => Some(TagName::new(s).map_err(ConfigError::Name)?),
None => layout_defaults.min_tag.clone(),
};
let exclude_tag_markers = p.exclude_tag_markers.unwrap_or_else(|| {
DEFAULT_EXCLUDE_TAG_MARKERS
.iter()
.map(|s| (*s).to_owned())
.collect()
});
let manifest = p
.manifest
.as_deref()
.map(ManifestSource::from_str)
.transpose()?
.unwrap_or_default();
let name = ProjectName::new(p.name).map_err(ConfigError::Name)?;
if matches!(layout, ProjectLayout::MultiApp | ProjectLayout::ErlangOtp) && app_roots.is_empty()
{
return Err(ConfigError::LayoutWithoutAppRoots {
project: name.to_string(),
layout: layout.label().to_owned(),
});
}
let kind = p
.kind
.as_deref()
.map(ProjectKind::from_str)
.transpose()?
.unwrap_or(ProjectKind::External);
let family = p
.family
.as_deref()
.map(ProjectFamily::from_str)
.transpose()?
.unwrap_or_default();
let source = match (kind, p.git_url) {
(ProjectKind::External, Some(g)) => ProjectSource::External {
git_url: PathBuf::from(g),
},
(ProjectKind::External, None) => {
return Err(ConfigError::ExternalProjectMissingGitUrl(name.to_string()));
}
(ProjectKind::SelfRepo, None) => ProjectSource::SelfRepo,
(ProjectKind::SelfRepo, Some(_)) => {
return Err(ConfigError::SelfProjectHasGitUrl(name.to_string()));
}
};
Ok(Project {
name,
source,
family,
language,
tag_prefix: p
.tag_prefix
.or(layout_defaults.tag_prefix)
.unwrap_or_else(|| String::from("v")),
public_modules: module_names(p.public_modules)?,
internal_modules: module_names(p.internal_modules)?,
layout,
scan_paths,
app_roots,
include_apps,
exclude_apps,
excluded_subdirs,
tag_pattern,
min_tag,
exclude_tag_markers,
manifest,
})
}
fn parse_app_names(raw: Vec<String>) -> Result<Vec<ApplicationName>, ConfigError> {
raw.into_iter()
.map(|s| ApplicationName::new(s).map_err(ConfigError::Name))
.collect()
}
fn parse_pin(pin: PinRaw) -> Result<PinSpec, ConfigError> {
match pin {
PinRaw::SelfBranch {
project,
branch,
repo_dir_path,
} => {
let project = ProjectName::new(project).map_err(ConfigError::Name)?;
let git_ref = GitRef::new(branch).map_err(ConfigError::Name)?;
Ok(PinSpec::SelfRef {
project,
git_ref,
repo_dir_path,
})
}
PinRaw::SelfSha {
project,
sha,
repo_dir_path,
} => {
let project = ProjectName::new(project).map_err(ConfigError::Name)?;
let git_ref = GitRef::new(sha).map_err(ConfigError::Name)?;
Ok(PinSpec::SelfRef {
project,
git_ref,
repo_dir_path,
})
}
PinRaw::Literal { project, tag } => {
let project = ProjectName::new(project).map_err(ConfigError::Name)?;
let tag = TagName::new(tag).map_err(ConfigError::Name)?;
Ok(PinSpec::literal(project, tag))
}
PinRaw::Pattern {
project,
tag_pattern,
select,
} => {
let project = ProjectName::new(project).map_err(ConfigError::Name)?;
let pattern = TagGlob::new(tag_pattern).map_err(ConfigError::Name)?;
let select = match select.as_str() {
"latest" => PinSelect::Latest,
"oldest" => PinSelect::Oldest,
other => return Err(ConfigError::PinUnknownSelect(other.to_owned())),
};
Ok(PinSpec::pattern(project, pattern, select))
}
}
}