Skip to main content

backhopper_core/config/
host.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//! `[[host]]`: the Erlang system plugins are loaded into, its release
6//! tags, pin source, release roots and supported Erlang ranges.
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::path::Path;
11use std::str::FromStr;
12
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16use crate::config::{Project, ProjectLayout};
17use crate::errors::ConfigError;
18use crate::model::names::{
19    ApplicationName, DependencyName, DependencyVersion, HostName, MakeVariableName, ProjectName,
20    RelativePath, SeriesTagPattern, vocabulary,
21};
22use crate::model::non_empty::NonEmpty;
23use crate::model::release_roots::{MakefileVariable, ReleaseRoots};
24use crate::model::release_version::{
25    ErlangMajor, ErlangVersionRange, HostVersion, ReleaseVersionError,
26};
27
28pub const ERLANG_OTP_TAG_PREFIX: &str = "OTP-";
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(deny_unknown_fields)]
32pub struct HostRaw {
33    pub name: String,
34    pub project: String,
35    pub otp_project: String,
36    pub series_tag_pattern: String,
37    pub erlang_majors: Vec<u32>,
38    pub pins: HostPinsRaw,
39    pub release_roots: ReleaseRootsRaw,
40    #[serde(default)]
41    pub erlang_support: Option<ErlangSupportRaw>,
42    #[serde(default)]
43    pub erlang_support_file_path: Option<String>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct HostPinsRaw {
49    pub source: String,
50    #[serde(default)]
51    pub file_path: Option<String>,
52    #[serde(default)]
53    pub releases: Option<BTreeMap<String, BTreeMap<String, String>>>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(deny_unknown_fields)]
58pub struct ReleaseRootsRaw {
59    #[serde(default)]
60    pub apps: Option<Vec<String>>,
61    #[serde(default)]
62    pub makefile_variable: Option<MakefileVariableRaw>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct MakefileVariableRaw {
68    pub file_path: String,
69    pub variable: String,
70}
71
72/// The two tables of `[host.erlang_support]`, also the whole content of
73/// an `erlang_support_file_path` file.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct ErlangSupportRaw {
77    pub default: String,
78    #[serde(default)]
79    pub releases: BTreeMap<String, String>,
80}
81
82vocabulary!(
83    pub enum PinSourceKind: "pin source" {
84        ErlangMk => "erlang_mk",
85        Table => "table",
86    }
87);
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90#[serde(rename_all = "snake_case")]
91pub enum PinSource {
92    ErlangMk { file_path: RelativePath },
93    Table(BTreeMap<HostVersion, BTreeMap<DependencyName, DependencyVersion>>),
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
97pub struct ErlangSupport {
98    default: ErlangVersionRange,
99    releases: BTreeMap<HostVersion, ErlangVersionRange>,
100}
101
102impl ErlangSupport {
103    #[must_use]
104    pub fn new(
105        default: ErlangVersionRange,
106        releases: BTreeMap<HostVersion, ErlangVersionRange>,
107    ) -> Self {
108        Self { default, releases }
109    }
110
111    /// The entry of the nearest listed release at or below `release`, and
112    /// `default` for anything below the first entry.
113    #[must_use]
114    pub fn range_for(&self, release: &HostVersion) -> &ErlangVersionRange {
115        self.releases
116            .range(..=release)
117            .next_back()
118            .map_or(&self.default, |(_, range)| range)
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
123pub struct Host {
124    pub name: HostName,
125    pub project: ProjectName,
126    pub otp_project: ProjectName,
127    pub series_tag_pattern: SeriesTagPattern,
128    pub erlang_majors: NonEmpty<ErlangMajor>,
129    pub pins: PinSource,
130    pub release_roots: ReleaseRoots,
131    pub erlang_support: ErlangSupport,
132}
133
134#[derive(Debug, Error)]
135pub enum HostConfigError {
136    #[error("host {host}: project {project:?} is not configured")]
137    UnknownProject { host: String, project: String },
138
139    #[error("host {host}: project {project} is the self project: host releases are tags")]
140    ProjectIsSelf { host: String, project: String },
141
142    #[error("host {host}: otp_project {project} must have layout = \"erlang_otp\"")]
143    OtpProjectLayout { host: String, project: String },
144
145    #[error(
146        "host {host}: otp_project {project} has tag_prefix {prefix:?}: it must be {ERLANG_OTP_TAG_PREFIX:?}"
147    )]
148    OtpProjectTagPrefix {
149        host: String,
150        project: String,
151        prefix: String,
152    },
153
154    #[error("host {host}: duplicate host name")]
155    Duplicate { host: String },
156
157    #[error("host {host}: erlang_majors must list at least one major")]
158    NoErlangMajors { host: String },
159
160    #[error("host {host}: erlang_majors: {source}")]
161    ErlangMajor {
162        host: String,
163        source: ReleaseVersionError,
164    },
165
166    #[error("host {host}: pins: {detail}")]
167    Pins { host: String, detail: String },
168
169    #[error("host {host}: release_roots: {detail}")]
170    ReleaseRoots { host: String, detail: &'static str },
171
172    #[error("host {host}: erlang_support and erlang_support_file_path are mutually exclusive")]
173    ErlangSupportBoth { host: String },
174
175    #[error("host {host}: one of erlang_support and erlang_support_file_path is required")]
176    ErlangSupportNeither { host: String },
177
178    #[error("host {host}: erlang support {location}: {source}")]
179    ErlangSupportEntry {
180        host: String,
181        location: String,
182        source: ReleaseVersionError,
183    },
184
185    #[error("host {host}: erlang_support_file_path {path}: {detail}")]
186    ErlangSupportFile {
187        host: String,
188        path: String,
189        detail: String,
190    },
191}
192
193impl Host {
194    pub(super) fn resolve(
195        raw: HostRaw,
196        config_dir: &Path,
197        projects: &[Project],
198    ) -> Result<Self, ConfigError> {
199        let host = raw.name.clone();
200        let name = HostName::new(raw.name)?;
201        let project = ProjectName::new(raw.project)?;
202        let otp_project = ProjectName::new(raw.otp_project)?;
203        check_host_project(&host, &project, projects)?;
204        check_otp_project(&host, &otp_project, projects)?;
205        let series_tag_pattern = SeriesTagPattern::new(raw.series_tag_pattern)?;
206        let erlang_majors = resolve_majors(&host, raw.erlang_majors)?;
207        let pins = resolve_pins(&host, raw.pins)?;
208        let release_roots = resolve_release_roots(&host, raw.release_roots)?;
209        let erlang_support = resolve_erlang_support(
210            &host,
211            raw.erlang_support,
212            raw.erlang_support_file_path,
213            config_dir,
214        )?;
215        Ok(Self {
216            name,
217            project,
218            otp_project,
219            series_tag_pattern,
220            erlang_majors,
221            pins,
222            release_roots,
223            erlang_support,
224        })
225    }
226}
227
228fn find_project<'p>(
229    host: &str,
230    name: &ProjectName,
231    projects: &'p [Project],
232) -> Result<&'p Project, HostConfigError> {
233    projects
234        .iter()
235        .find(|p| &p.name == name)
236        .ok_or_else(|| HostConfigError::UnknownProject {
237            host: host.to_owned(),
238            project: name.to_string(),
239        })
240}
241
242fn check_host_project(
243    host: &str,
244    name: &ProjectName,
245    projects: &[Project],
246) -> Result<(), HostConfigError> {
247    if find_project(host, name, projects)?.is_self() {
248        return Err(HostConfigError::ProjectIsSelf {
249            host: host.to_owned(),
250            project: name.to_string(),
251        });
252    }
253    Ok(())
254}
255
256fn check_otp_project(
257    host: &str,
258    name: &ProjectName,
259    projects: &[Project],
260) -> Result<(), HostConfigError> {
261    let project = find_project(host, name, projects)?;
262    if project.layout != ProjectLayout::ErlangOtp {
263        return Err(HostConfigError::OtpProjectLayout {
264            host: host.to_owned(),
265            project: name.to_string(),
266        });
267    }
268    if project.tag_prefix != ERLANG_OTP_TAG_PREFIX {
269        return Err(HostConfigError::OtpProjectTagPrefix {
270            host: host.to_owned(),
271            project: name.to_string(),
272            prefix: project.tag_prefix.clone(),
273        });
274    }
275    Ok(())
276}
277
278fn resolve_majors(host: &str, raw: Vec<u32>) -> Result<NonEmpty<ErlangMajor>, HostConfigError> {
279    let majors = raw
280        .into_iter()
281        .map(ErlangMajor::try_from)
282        .collect::<Result<Vec<_>, _>>()
283        .map_err(|source| HostConfigError::ErlangMajor {
284            host: host.to_owned(),
285            source,
286        })?;
287    let mut majors = NonEmpty::try_from(majors).map_err(|_| HostConfigError::NoErlangMajors {
288        host: host.to_owned(),
289    })?;
290    majors.sort_dedup();
291    Ok(majors)
292}
293
294fn resolve_pins(host: &str, raw: HostPinsRaw) -> Result<PinSource, ConfigError> {
295    let pins_error = |detail: String| HostConfigError::Pins {
296        host: host.to_owned(),
297        detail,
298    };
299    match PinSourceKind::from_str(&raw.source)? {
300        PinSourceKind::ErlangMk => {
301            if raw.releases.is_some() {
302                return Err(pins_error("releases is only for source = \"table\"".into()).into());
303            }
304            let file_path = raw
305                .file_path
306                .ok_or_else(|| pins_error("source = \"erlang_mk\" needs file_path".into()))?;
307            Ok(PinSource::ErlangMk {
308                file_path: RelativePath::new(file_path)?,
309            })
310        }
311        PinSourceKind::Table => {
312            if raw.file_path.is_some() {
313                return Err(
314                    pins_error("file_path is only for source = \"erlang_mk\"".into()).into(),
315                );
316            }
317            let releases = raw
318                .releases
319                .filter(|releases| !releases.is_empty())
320                .ok_or_else(|| {
321                    pins_error("source = \"table\" needs at least one release".into())
322                })?;
323            let mut table = BTreeMap::new();
324            for (release, deps) in releases {
325                let version = HostVersion::from_str(&release)
326                    .map_err(|e| pins_error(format!("release {release:?}: {e}")))?;
327                let mut pins = BTreeMap::new();
328                for (dep, dep_version) in deps {
329                    pins.insert(
330                        DependencyName::new(dep)?,
331                        DependencyVersion::new(dep_version)?,
332                    );
333                }
334                table.insert(version, pins);
335            }
336            Ok(PinSource::Table(table))
337        }
338    }
339}
340
341fn resolve_release_roots(host: &str, raw: ReleaseRootsRaw) -> Result<ReleaseRoots, ConfigError> {
342    let roots_error = |detail: &'static str| HostConfigError::ReleaseRoots {
343        host: host.to_owned(),
344        detail,
345    };
346    let apps = match raw.apps {
347        Some(apps) => {
348            let apps = apps
349                .into_iter()
350                .map(ApplicationName::new)
351                .collect::<Result<Vec<_>, _>>()?;
352            Some(NonEmpty::try_from(apps).map_err(|_| roots_error("apps is empty"))?)
353        }
354        None => None,
355    };
356    let variable = raw
357        .makefile_variable
358        .map(|raw| {
359            Ok::<_, ConfigError>(MakefileVariable {
360                file_path: RelativePath::new(raw.file_path)?,
361                variable: MakeVariableName::new(raw.variable)?,
362            })
363        })
364        .transpose()?;
365    match (apps, variable) {
366        (Some(apps), None) => Ok(ReleaseRoots::Apps(apps)),
367        (None, Some(variable)) => Ok(ReleaseRoots::Variable(variable)),
368        (Some(apps), Some(variable)) => Ok(ReleaseRoots::Both { apps, variable }),
369        (None, None) => Err(roots_error("needs apps, makefile_variable or both").into()),
370    }
371}
372
373fn resolve_erlang_support(
374    host: &str,
375    inline: Option<ErlangSupportRaw>,
376    file_path: Option<String>,
377    config_dir: &Path,
378) -> Result<ErlangSupport, ConfigError> {
379    let tables = match (inline, file_path) {
380        (Some(_), Some(_)) => {
381            return Err(HostConfigError::ErlangSupportBoth {
382                host: host.to_owned(),
383            }
384            .into());
385        }
386        (None, None) => {
387            return Err(HostConfigError::ErlangSupportNeither {
388                host: host.to_owned(),
389            }
390            .into());
391        }
392        (Some(tables), None) => tables,
393        (None, Some(path)) => read_support_file(host, &path, config_dir)?,
394    };
395    let entry_error = |location: String| {
396        let host = host.to_owned();
397        move |source| HostConfigError::ErlangSupportEntry {
398            host: host.clone(),
399            location: location.clone(),
400            source,
401        }
402    };
403    let default =
404        ErlangVersionRange::from_str(&tables.default).map_err(entry_error("default".to_owned()))?;
405    let mut releases = BTreeMap::new();
406    for (release, range) in &tables.releases {
407        let location = format!("release {release:?}");
408        let version = HostVersion::from_str(release).map_err(entry_error(location.clone()))?;
409        let range = ErlangVersionRange::from_str(range).map_err(entry_error(location))?;
410        releases.insert(version, range);
411    }
412    Ok(ErlangSupport::new(default, releases))
413}
414
415fn read_support_file(
416    host: &str,
417    path: &str,
418    config_dir: &Path,
419) -> Result<ErlangSupportRaw, HostConfigError> {
420    let file_error = |detail: String| HostConfigError::ErlangSupportFile {
421        host: host.to_owned(),
422        path: path.to_owned(),
423        detail,
424    };
425    let text = fs::read_to_string(config_dir.join(path)).map_err(|e| file_error(e.to_string()))?;
426    toml::from_str(&text).map_err(|e| file_error(e.to_string()))
427}