1use 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#[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 #[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}