use std::path::Path;
use crate::error::ToolchainError;
pub struct Workspace {
temp: tempfile::TempDir,
}
impl Workspace {
pub fn stage(template_root: &Path) -> Result<Self, ToolchainError> {
let parent = template_root
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.ok_or_else(|| ToolchainError::InvalidProject {
message: format!(
"authoring project root `{}` has no parent directory to host an isolated build workspace; the template must be provisioned inside a writable parent directory",
template_root.display()
),
})?;
let temp = tempfile::Builder::new()
.prefix("aion-authoring-submission-")
.tempdir_in(parent)
.map_err(|source| ToolchainError::Io {
path: parent.to_path_buf(),
source,
})?;
copy_tree(template_root, temp.path())?;
Ok(Self { temp })
}
#[must_use]
pub fn root(&self) -> &Path {
self.temp.path()
}
}
impl std::fmt::Debug for Workspace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Workspace")
.field("root", &self.temp.path())
.finish()
}
}
fn copy_tree(from: &Path, to: &Path) -> Result<(), ToolchainError> {
std::fs::create_dir_all(to).map_err(|source| ToolchainError::Io {
path: to.to_path_buf(),
source,
})?;
let entries = std::fs::read_dir(from).map_err(|source| ToolchainError::Io {
path: from.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| ToolchainError::Io {
path: from.to_path_buf(),
source,
})?;
let file_type = entry.file_type().map_err(|source| ToolchainError::Io {
path: entry.path(),
source,
})?;
let source_path = entry.path();
let target_path = to.join(entry.file_name());
if file_type.is_dir() {
copy_tree(&source_path, &target_path)?;
} else {
std::fs::copy(&source_path, &target_path).map_err(|source| ToolchainError::Io {
path: source_path.clone(),
source,
})?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::Workspace;
use crate::error::ToolchainError;
fn template() -> Result<(tempfile::TempDir, std::path::PathBuf), Box<dyn std::error::Error>> {
let parent = tempfile::Builder::new()
.prefix("aion-toolchain-workspace-template-")
.tempdir()?;
let root = parent.path().join("project");
std::fs::create_dir_all(root.join("src/nested"))?;
std::fs::create_dir_all(root.join("schemas"))?;
std::fs::write(root.join("gleam.toml"), b"name = \"demo\"\n")?;
std::fs::write(root.join("workflow.toml"), b"[[workflow]]\n")?;
std::fs::write(root.join("src/demo.gleam"), b"pub fn run() { Nil }\n")?;
std::fs::write(root.join("src/nested/helper.gleam"), b"pub const x = 1\n")?;
std::fs::write(root.join("schemas/input.json"), b"{}\n")?;
Ok((parent, root))
}
#[test]
fn stage_copies_the_full_tree_into_an_isolated_root() -> Result<(), Box<dyn std::error::Error>>
{
let (_parent, template_root) = template()?;
let workspace = Workspace::stage(&template_root)?;
let root = workspace.root();
assert_ne!(
root, template_root,
"the workspace root is not the template"
);
assert!(root.join("gleam.toml").is_file());
assert!(root.join("workflow.toml").is_file());
assert!(root.join("src/demo.gleam").is_file());
assert!(
root.join("src/nested/helper.gleam").is_file(),
"nested src modules are copied"
);
assert!(root.join("schemas/input.json").is_file());
assert_eq!(
std::fs::read(root.join("src/demo.gleam"))?,
std::fs::read(template_root.join("src/demo.gleam"))?,
"copied bytes match the template"
);
Ok(())
}
#[test]
fn stage_places_the_workspace_as_a_same_depth_sibling_of_the_template()
-> Result<(), Box<dyn std::error::Error>> {
let (_parent, template_root) = template()?;
let template_parent = template_root.parent().ok_or("template has a parent")?;
let workspace = Workspace::stage(&template_root)?;
assert_eq!(
workspace.root().parent(),
Some(template_parent),
"the workspace root is a same-depth sibling of the template under the same parent"
);
assert_eq!(
workspace.root().components().count(),
template_root.components().count(),
"the workspace root sits at the same directory depth as the template"
);
Ok(())
}
#[test]
fn dropping_the_workspace_removes_the_temp_dir_and_leaves_the_template()
-> Result<(), Box<dyn std::error::Error>> {
let (_parent, template_root) = template()?;
let workspace = Workspace::stage(&template_root)?;
let staged_root = workspace.root().to_path_buf();
assert!(staged_root.join("gleam.toml").is_file());
std::fs::write(staged_root.join("src/demo.gleam"), b"// overwritten\n")?;
drop(workspace);
assert!(
!staged_root.exists(),
"the workspace temp dir (the working-copy root) is removed on drop"
);
assert!(
template_root.join("gleam.toml").is_file(),
"the template is left intact"
);
assert_eq!(
std::fs::read(template_root.join("src/demo.gleam"))?,
b"pub fn run() { Nil }\n",
"the template source is never mutated by a submission"
);
Ok(())
}
#[test]
fn two_submissions_stage_into_distinct_isolated_roots() -> Result<(), Box<dyn std::error::Error>>
{
let (_parent, template_root) = template()?;
let first = Workspace::stage(&template_root)?;
let second = Workspace::stage(&template_root)?;
assert_ne!(
first.root(),
second.root(),
"concurrent submissions never share a working-copy root"
);
std::fs::write(first.root().join("src/demo.gleam"), b"// first\n")?;
std::fs::write(second.root().join("src/demo.gleam"), b"// second\n")?;
assert_eq!(
std::fs::read(first.root().join("src/demo.gleam"))?,
b"// first\n",
"the first workspace is unaffected by writes to the second"
);
Ok(())
}
#[test]
fn stage_rejects_a_template_without_a_parent_directory() {
let result = Workspace::stage(Path::new("/"));
assert!(
matches!(result, Err(ToolchainError::InvalidProject { .. })),
"a parentless template root is a typed InvalidProject, never a panic"
);
}
}