hara_native/project/production/
plan.rs1use 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}