Skip to main content

hara_native/project/production/
plan.rs

1use crate::kernel::{parse, Form};
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub struct BuildPlan {
6    pub project_id: String,
7    pub project_version: String,
8    pub profile: String,
9    pub language: String,
10    pub main: String,
11    pub entrypoints: Vec<String>,
12    pub default_entrypoint: String,
13    pub keep_vars: Vec<String>,
14    pub keep_namespaces: Vec<String>,
15    pub output_bundle: String,
16    pub output_report: String,
17}
18
19impl BuildPlan {
20    pub fn parse(source: &str) -> Result<Self, String> {
21        let form =
22            parse(source).map_err(|error| format!("invalid production build plan: {error}"))?;
23        let entries = map_entries(&form, "production build plan must be an EDN map")?;
24        let project_id = scalar(required(entries, "project/id")?, ":project/id")?;
25        let project_version = string(required(entries, "project/version")?, ":project/version")?;
26        let profile = identifier(required(entries, "profile/name")?, ":profile/name")?;
27        let language = identifier(required(entries, "profile/language")?, ":profile/language")?;
28        let main = scalar(required(entries, "profile/main")?, ":profile/main")?;
29        let tree_shake = boolean(required(entries, "build/tree-shake")?, ":build/tree-shake")?;
30        let entrypoints = symbol_vector(
31            required(entries, "build/entrypoints")?,
32            ":build/entrypoints",
33            true,
34        )?;
35        let default_entrypoint = optional(entries, "build/default-entrypoint")
36            .map(|value| qualified_symbol(value, ":build/default-entrypoint"))
37            .transpose()?;
38        let keep_vars = symbol_vector(
39            required(entries, "build/keep-vars")?,
40            ":build/keep-vars",
41            true,
42        )?;
43        let keep_namespaces = symbol_vector(
44            required(entries, "build/keep-namespaces")?,
45            ":build/keep-namespaces",
46            false,
47        )?;
48        let output_bundle = string(
49            required(entries, "build/output-bundle")?,
50            ":build/output-bundle",
51        )?;
52        let output_report = string(
53            required(entries, "build/output-report")?,
54            ":build/output-report",
55        )?;
56        if language != "hara" {
57            return Err("production build plan must use :profile/language :hara".into());
58        }
59        if !tree_shake {
60            return Err("production build plan must opt in with :build/tree-shake true".into());
61        }
62        if entrypoints.is_empty() {
63            return Err("production build plan has no entrypoints".into());
64        }
65        let default_entrypoint = match default_entrypoint {
66            Some(default) => default,
67            None if entrypoints.len() == 1 => entrypoints[0].clone(),
68            None => {
69                return Err(
70                    "production build plan with multiple entrypoints requires :build/default-entrypoint"
71                        .into(),
72                )
73            }
74        };
75        if !entrypoints.contains(&default_entrypoint) {
76            return Err(":build/default-entrypoint must name one of :build/entrypoints".into());
77        }
78        Ok(Self {
79            project_id,
80            project_version,
81            profile,
82            language,
83            main,
84            entrypoints,
85            default_entrypoint,
86            keep_vars,
87            keep_namespaces,
88            output_bundle,
89            output_report,
90        })
91    }
92
93    pub fn report_path(&self, root: &std::path::Path) -> PathBuf {
94        root.join(&self.output_report)
95    }
96}
97
98fn map_entries<'a>(form: &'a Form, message: &str) -> Result<&'a [(Form, Form)], String> {
99    match form {
100        Form::Map(entries) => Ok(entries),
101        _ => Err(message.into()),
102    }
103}
104
105fn required<'a>(entries: &'a [(Form, Form)], key: &str) -> Result<&'a Form, String> {
106    entries
107        .iter()
108        .find_map(|(candidate, value)| {
109            matches!(candidate, Form::Keyword(name) if name == key).then_some(value)
110        })
111        .ok_or_else(|| format!("production build plan is missing :{key}"))
112}
113
114fn optional<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
115    entries.iter().find_map(|(candidate, value)| {
116        matches!(candidate, Form::Keyword(name) if name == key).then_some(value)
117    })
118}
119
120fn scalar(form: &Form, label: &str) -> Result<String, String> {
121    match form {
122        Form::Symbol(value) | Form::String(value) => Ok(value.clone()),
123        _ => Err(format!("{label} must be a symbol or string")),
124    }
125}
126
127fn string(form: &Form, label: &str) -> Result<String, String> {
128    match form {
129        Form::String(value) => Ok(value.clone()),
130        _ => Err(format!("{label} must be a string")),
131    }
132}
133
134fn boolean(form: &Form, label: &str) -> Result<bool, String> {
135    match form {
136        Form::Bool(value) => Ok(*value),
137        _ => Err(format!("{label} must be a boolean")),
138    }
139}
140
141fn identifier(form: &Form, label: &str) -> Result<String, String> {
142    match form {
143        Form::Keyword(value) | Form::Symbol(value) | Form::String(value) => Ok(value.clone()),
144        _ => Err(format!("{label} must be a keyword, symbol, or string")),
145    }
146}
147
148fn qualified_symbol(form: &Form, label: &str) -> Result<String, String> {
149    let value = scalar(form, label)?;
150    if qualified_var(&value) {
151        Ok(value)
152    } else {
153        Err(format!("{label} must be a qualified Var symbol"))
154    }
155}
156
157fn symbol_vector(form: &Form, label: &str, qualified: bool) -> Result<Vec<String>, String> {
158    let Form::Vector(values) = form else {
159        return Err(format!("{label} must be a vector"));
160    };
161    let mut output = values
162        .iter()
163        .map(|value| scalar(value, label))
164        .collect::<Result<Vec<_>, _>>()?;
165    if qualified && output.iter().any(|value| !qualified_var(value)) {
166        return Err(format!("{label} must contain qualified Var symbols"));
167    }
168    if !qualified && output.iter().any(|value| value.contains('/')) {
169        return Err(format!("{label} must contain namespace symbols"));
170    }
171    output.sort();
172    output.dedup();
173    Ok(output)
174}
175
176fn qualified_var(value: &str) -> bool {
177    let Some((namespace, name)) = value.split_once('/') else {
178        return false;
179    };
180    !namespace.is_empty() && !name.is_empty() && !name.contains('/')
181}
182
183#[cfg(test)]
184mod tests {
185    use super::BuildPlan;
186
187    fn source(entrypoints: &str, default: &str) -> String {
188        format!(
189            "{{:project/id demo-app \
190              :project/version \"0.1.0\" \
191              :profile/name :production \
192              :profile/language :hara \
193              :profile/main app.main \
194              :build/tree-shake true \
195              :build/entrypoints [{entrypoints}] \
196              {default} \
197              :build/keep-vars [] \
198              :build/keep-namespaces [] \
199              :build/output-bundle \"target/app.hbx\" \
200              :build/output-report \"target/app.shake.edn\"}}"
201        )
202    }
203
204    #[test]
205    fn infers_the_only_entrypoint_as_default() {
206        let plan = BuildPlan::parse(&source("app.main/start", "")).unwrap();
207        assert_eq!(plan.default_entrypoint, "app.main/start");
208    }
209
210    #[test]
211    fn requires_a_default_for_multiple_entrypoints() {
212        let error = BuildPlan::parse(&source("app.main/start app.main/worker", "")).unwrap_err();
213        assert!(error.contains("requires :build/default-entrypoint"));
214    }
215
216    #[test]
217    fn validates_the_default_against_the_entrypoint_set() {
218        let error = BuildPlan::parse(&source(
219            "app.main/start",
220            ":build/default-entrypoint app.main/missing",
221        ))
222        .unwrap_err();
223        assert!(error.contains("must name one of"));
224    }
225}