1use std::{
11 collections::BTreeMap,
12 path::{Component, Path, PathBuf},
13};
14
15use crate::error::ToolchainError;
16
17const GLEAM_CONFIG_FILE: &str = "gleam.toml";
19
20const WORKFLOW_CONFIG_FILE: &str = "workflow.toml";
22
23#[derive(serde::Deserialize)]
28struct EntryConfig {
29 #[serde(default)]
30 workflow: Vec<EntryWorkflow>,
31}
32
33#[derive(serde::Deserialize)]
34struct EntryWorkflow {
35 entry_module: String,
36}
37
38#[derive(serde::Deserialize)]
39struct GleamProjectConfig {
40 name: String,
41 #[serde(default)]
42 dependencies: BTreeMap<String, toml::Value>,
43 #[serde(default, rename = "dev-dependencies")]
44 dev_dependencies: BTreeMap<String, toml::Value>,
45}
46
47pub fn validate_project_root(root: &Path) -> Result<(), ToolchainError> {
54 if !root.join(GLEAM_CONFIG_FILE).is_file() {
55 return Err(ToolchainError::InvalidProject {
56 message: format!(
57 "{} not found under the authoring project root `{}`; the root must be a built Gleam project",
58 GLEAM_CONFIG_FILE,
59 root.display()
60 ),
61 });
62 }
63 if !root.join(WORKFLOW_CONFIG_FILE).is_file() {
64 return Err(ToolchainError::InvalidProject {
65 message: format!(
66 "{} not found under the authoring project root `{}`; the root must declare its workflow packaging descriptor",
67 WORKFLOW_CONFIG_FILE,
68 root.display()
69 ),
70 });
71 }
72 Ok(())
73}
74
75pub fn single_entry_module(root: &Path) -> Result<String, ToolchainError> {
87 let descriptor = root.join(WORKFLOW_CONFIG_FILE);
88 let text = std::fs::read_to_string(&descriptor).map_err(|source| ToolchainError::Io {
89 path: descriptor.clone(),
90 source,
91 })?;
92 let config: EntryConfig =
93 toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
94 message: format!("failed to parse {}: {source}", descriptor.display()),
95 })?;
96 match config.workflow.as_slice() {
97 [single] => Ok(single.entry_module.clone()),
98 [] => Err(ToolchainError::InvalidProject {
99 message: format!(
100 "{} declares no [[workflow]] entry; source submission requires exactly one",
101 descriptor.display()
102 ),
103 }),
104 many => Err(ToolchainError::InvalidProject {
105 message: format!(
106 "{} declares {} [[workflow]] entries; source submission requires exactly one entry module to write the submitted source into",
107 descriptor.display(),
108 many.len()
109 ),
110 }),
111 }
112}
113
114pub(crate) fn canonical_entry_module(entry_module: &str) -> Result<String, ToolchainError> {
125 if !is_supported_logical_module(entry_module) {
126 return Err(ToolchainError::InvalidProject {
127 message: format!(
128 "entry module `{entry_module}` is not a supported Gleam logical module name (each `/` or `@` separated component must match `[a-z][a-z0-9_]*`)"
129 ),
130 });
131 }
132 Ok(entry_module.replace('/', "@"))
133}
134
135pub fn entry_module_source_path(
148 root: &Path,
149 entry_module: &str,
150) -> Result<PathBuf, ToolchainError> {
151 let canonical = canonical_entry_module(entry_module)?;
152 let src_root = root.join("src");
153 let relative: PathBuf = canonical.split('@').collect::<PathBuf>();
154 let mut candidate = src_root.join(relative);
155 candidate.set_extension("gleam");
156
157 if !is_confined(&src_root, &candidate) {
161 return Err(ToolchainError::InvalidProject {
162 message: format!(
163 "entry module `{entry_module}` resolves outside the project src directory `{}`",
164 src_root.display()
165 ),
166 });
167 }
168 Ok(candidate)
169}
170
171pub fn write_entry_source(path: &Path, source: &str) -> Result<(), ToolchainError> {
179 if let Some(parent) = path.parent() {
180 std::fs::create_dir_all(parent).map_err(|io| ToolchainError::Io {
181 path: parent.to_path_buf(),
182 source: io,
183 })?;
184 }
185 std::fs::write(path, source.as_bytes()).map_err(|io| ToolchainError::Io {
186 path: path.to_path_buf(),
187 source: io,
188 })
189}
190
191pub(crate) fn retarget_single_entry_module(
198 root: &Path,
199 entry_module: &str,
200) -> Result<(), ToolchainError> {
201 let entry_module = canonical_entry_module(entry_module)?;
202 drop(entry_module_source_path(root, &entry_module)?);
203 let descriptor = root.join(WORKFLOW_CONFIG_FILE);
204 let text = std::fs::read_to_string(&descriptor).map_err(|source| ToolchainError::Io {
205 path: descriptor.clone(),
206 source,
207 })?;
208 let mut config: toml::Value =
209 toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
210 message: format!("failed to parse {}: {source}", descriptor.display()),
211 })?;
212 let workflows = config
213 .get_mut("workflow")
214 .and_then(toml::Value::as_array_mut)
215 .ok_or_else(|| ToolchainError::InvalidProject {
216 message: format!(
217 "{} declares no [[workflow]] entry; source submission requires exactly one",
218 descriptor.display()
219 ),
220 })?;
221 let workflow = match workflows.as_mut_slice() {
222 [single] => single,
223 many => {
224 return Err(ToolchainError::InvalidProject {
225 message: format!(
226 "{} declares {} [[workflow]] entries; source submission requires exactly one entry module to write the submitted source into",
227 descriptor.display(),
228 many.len()
229 ),
230 });
231 }
232 };
233 let table = workflow
234 .as_table_mut()
235 .ok_or_else(|| ToolchainError::InvalidProject {
236 message: format!(
237 "{} contains a non-table [[workflow]] entry",
238 descriptor.display()
239 ),
240 })?;
241 table.insert("entry_module".to_owned(), toml::Value::String(entry_module));
242 let adjusted = toml::to_string(&config).map_err(|source| ToolchainError::InvalidProject {
243 message: format!("failed to serialize {}: {source}", descriptor.display()),
244 })?;
245 std::fs::write(&descriptor, adjusted).map_err(|source| ToolchainError::Io {
246 path: descriptor,
247 source,
248 })
249}
250
251pub(crate) fn remove_entry_source(path: &Path) -> Result<(), ToolchainError> {
255 match std::fs::remove_file(path) {
256 Ok(()) => Ok(()),
257 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
258 Err(source) => Err(ToolchainError::Io {
259 path: path.to_path_buf(),
260 source,
261 }),
262 }
263}
264
265pub(crate) fn remove_staged_root_build(root: &Path) -> Result<(), ToolchainError> {
273 let package = root_package_name(root)?;
274 let build = root.join("build").join("dev").join("erlang").join(package);
275 match std::fs::remove_dir_all(&build) {
276 Ok(()) => Ok(()),
277 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
278 Err(source) => Err(ToolchainError::Io {
279 path: build,
280 source,
281 }),
282 }
283}
284
285pub(crate) fn retarget_root_package(root: &Path, entry_module: &str) -> Result<(), ToolchainError> {
293 let canonical = canonical_entry_module(entry_module)?;
294 let package = document_package_name(&canonical);
295 let descriptor = root.join(GLEAM_CONFIG_FILE);
296 let text = std::fs::read_to_string(&descriptor).map_err(|source| ToolchainError::Io {
297 path: descriptor.clone(),
298 source,
299 })?;
300 let project: GleamProjectConfig =
301 toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
302 message: format!("failed to parse {}: {source}", descriptor.display()),
303 })?;
304 if project.dependencies.contains_key(&package)
305 || project.dev_dependencies.contains_key(&package)
306 {
307 return Err(ToolchainError::InvalidProject {
308 message: format!(
309 "document package name `{package}` derived from entry module `{canonical}` collides with a Gleam dependency"
310 ),
311 });
312 }
313
314 let mut config: toml::Value =
315 toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
316 message: format!("failed to parse {}: {source}", descriptor.display()),
317 })?;
318 let table = config
319 .as_table_mut()
320 .ok_or_else(|| ToolchainError::InvalidProject {
321 message: format!("{} must contain a TOML table", descriptor.display()),
322 })?;
323 table.insert("name".to_owned(), toml::Value::String(package));
324 let adjusted = toml::to_string(&config).map_err(|source| ToolchainError::InvalidProject {
325 message: format!("failed to serialize {}: {source}", descriptor.display()),
326 })?;
327 std::fs::write(&descriptor, adjusted).map_err(|source| ToolchainError::Io {
328 path: descriptor,
329 source,
330 })
331}
332
333fn document_package_name(canonical_entry: &str) -> String {
334 canonical_entry.replace('@', "__")
335}
336
337pub(crate) fn remove_generated_root_main_beam(root: &Path) -> Result<(), ToolchainError> {
346 let package = root_package_name(root)?;
347 let artifact = root
348 .join("build")
349 .join("dev")
350 .join("erlang")
351 .join(&package)
352 .join("ebin")
353 .join(format!("{package}@@main.beam"));
354 match std::fs::remove_file(&artifact) {
355 Ok(()) => Ok(()),
356 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
357 Err(source) => Err(ToolchainError::Io {
358 path: artifact,
359 source,
360 }),
361 }
362}
363
364fn root_package_name(root: &Path) -> Result<String, ToolchainError> {
365 let descriptor = root.join(GLEAM_CONFIG_FILE);
366 let text = std::fs::read_to_string(&descriptor).map_err(|source| ToolchainError::Io {
367 path: descriptor.clone(),
368 source,
369 })?;
370 let config: GleamProjectConfig =
371 toml::from_str(&text).map_err(|source| ToolchainError::InvalidProject {
372 message: format!("failed to parse {}: {source}", descriptor.display()),
373 })?;
374 if !is_gleam_name_component(&config.name) {
375 return Err(ToolchainError::InvalidProject {
376 message: format!(
377 "Gleam package name `{}` must match `[a-z][a-z0-9_]*`",
378 config.name
379 ),
380 });
381 }
382 Ok(config.name)
383}
384
385fn is_confined(base: &Path, candidate: &Path) -> bool {
390 let mut depth: i64 = 0;
391 let Ok(relative) = candidate.strip_prefix(base) else {
392 return false;
393 };
394 for component in relative.components() {
395 match component {
396 Component::CurDir => {}
397 Component::Normal(_) => depth += 1,
398 Component::ParentDir => {
399 depth -= 1;
400 if depth < 0 {
401 return false;
402 }
403 }
404 Component::RootDir | Component::Prefix(_) => return false,
407 }
408 }
409 depth >= 0
410}
411
412fn is_supported_logical_module(logical_name: &str) -> bool {
414 logical_name.split(['/', '@']).all(is_gleam_name_component)
415}
416
417fn is_gleam_name_component(component: &str) -> bool {
418 let mut bytes = component.bytes();
419 bytes.next().is_some_and(|first| first.is_ascii_lowercase())
420 && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
421}
422
423#[cfg(test)]
424#[path = "project_tests.rs"]
425mod tests;