Skip to main content

backhopper_core/model/
release_roots.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//! The apps a host release is rooted at: the starting set whose closure
6//! over the app manifests is what the release ships.
7
8use std::str::FromStr;
9
10use serde::Serialize;
11use thiserror::Error;
12
13use crate::erlang_mk::MakeVariables;
14use crate::model::app_manifest::ManifestUnreadable;
15use crate::model::names::{ApplicationName, MakeVariableName, RelativePath};
16use crate::model::non_empty::NonEmpty;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct MakefileVariable {
20    pub file_path: RelativePath,
21    pub variable: MakeVariableName,
22}
23
24/// At least one root between the literal list and the variable: an enum
25/// rather than two optional fields, so "no roots" is not a value.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27pub enum ReleaseRoots {
28    Apps(NonEmpty<ApplicationName>),
29    Variable(MakefileVariable),
30    Both {
31        apps: NonEmpty<ApplicationName>,
32        variable: MakefileVariable,
33    },
34}
35
36#[derive(Debug, Error, PartialEq, Eq)]
37pub enum ReleaseRootsError {
38    #[error("{file_path} is not present at the tag")]
39    FileMissing { file_path: RelativePath },
40
41    #[error("{variable} in {file_path} cannot be read: {reason:?}")]
42    VariableUnreadable {
43        file_path: RelativePath,
44        variable: MakeVariableName,
45        reason: ManifestUnreadable,
46    },
47
48    #[error("{variable} in {file_path} lists no apps and no literal roots are configured")]
49    NoRoots {
50        file_path: RelativePath,
51        variable: MakeVariableName,
52    },
53
54    #[error("{word:?} in {variable} is not an app name")]
55    NotAnApp {
56        variable: MakeVariableName,
57        word: String,
58    },
59}
60
61impl ReleaseRoots {
62    pub fn read(
63        &self,
64        read_file: impl Fn(&RelativePath) -> Option<String>,
65    ) -> Result<NonEmpty<ApplicationName>, ReleaseRootsError> {
66        match self {
67            Self::Apps(apps) => Ok(apps.clone()),
68            Self::Variable(variable) => {
69                let words = variable_roots(variable, &read_file)?;
70                NonEmpty::try_from(words).map_err(|_| ReleaseRootsError::NoRoots {
71                    file_path: variable.file_path.clone(),
72                    variable: variable.variable.clone(),
73                })
74            }
75            Self::Both { apps, variable } => {
76                let more = variable_roots(variable, &read_file)?;
77                Ok(NonEmpty::new(
78                    apps.first().clone(),
79                    apps.iter().skip(1).cloned().chain(more),
80                ))
81            }
82        }
83    }
84}
85
86fn variable_roots(
87    variable: &MakefileVariable,
88    read_file: &impl Fn(&RelativePath) -> Option<String>,
89) -> Result<Vec<ApplicationName>, ReleaseRootsError> {
90    let text = read_file(&variable.file_path).ok_or_else(|| ReleaseRootsError::FileMissing {
91        file_path: variable.file_path.clone(),
92    })?;
93    let variables = MakeVariables::parse(&text);
94    let words = variables.words(&variable.variable).map_err(|reason| {
95        ReleaseRootsError::VariableUnreadable {
96            file_path: variable.file_path.clone(),
97            variable: variable.variable.clone(),
98            reason,
99        }
100    })?;
101    words
102        .iter()
103        .map(|word| {
104            ApplicationName::from_str(word).map_err(|_| ReleaseRootsError::NotAnApp {
105                variable: variable.variable.clone(),
106                word: word.clone(),
107            })
108        })
109        .collect()
110}