use crate::descriptor::{self, Descriptor};
use crate::{build, compile, fmt, jar, run, test};
use anyhow::{bail, Context, Result};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug)]
pub struct Member {
pub path: PathBuf,
pub declared: String,
pub descriptor: Descriptor,
pub workspace_deps: Vec<usize>,
}
#[derive(Debug)]
pub struct Workspace {
pub root: PathBuf,
pub members: Vec<Member>,
}
#[derive(Debug)]
pub enum WorkspaceContext {
WorkspaceRoot(PathBuf),
WorkspaceMember {
workspace_root: PathBuf,
member_index: usize,
},
Standalone(PathBuf),
}
pub fn discover(project: &Path) -> Result<WorkspaceContext> {
let desc = descriptor::load(project)?;
if desc.is_workspace() {
return Ok(WorkspaceContext::WorkspaceRoot(project.to_path_buf()));
}
let project_canon = project
.canonicalize()
.with_context(|| format!("failed to canonicalize {}", project.display()))?;
let mut cur = project.parent();
while let Some(dir) = cur {
if dir.join("Curie.toml").exists() {
if let Ok(d) = descriptor::load(dir) {
if let Some(ws) = d.workspace() {
for declared in &ws.members {
if let Ok(canon) = dir.join(declared).canonicalize() {
if canon == project_canon {
let ws_loaded = load(dir)?;
let topo_idx = ws_loaded
.members
.iter()
.position(|m| {
m.path.canonicalize().ok() == Some(canon.clone())
})
.expect("member existed at raw-list time, must exist post-sort");
return Ok(WorkspaceContext::WorkspaceMember {
workspace_root: dir.to_path_buf(),
member_index: topo_idx,
});
}
}
}
}
}
}
cur = dir.parent();
}
Ok(WorkspaceContext::Standalone(project.to_path_buf()))
}
pub fn load(workspace_root: &Path) -> Result<Workspace> {
let root_desc = descriptor::load(workspace_root)
.with_context(|| format!("failed to load workspace at {}", workspace_root.display()))?;
let ws = root_desc
.workspace()
.ok_or_else(|| anyhow::anyhow!(
"{} is not a workspace: its Curie.toml has no [workspace] section",
workspace_root.display(),
))?;
let mut raw_members: Vec<Member> = Vec::with_capacity(ws.members.len());
for declared in &ws.members {
let path = workspace_root.join(declared);
if !path.exists() {
bail!(
"workspace member \"{}\" not found at {}",
declared,
path.display(),
);
}
let mut descriptor = descriptor::load(&path)
.with_context(|| format!("failed to load workspace member \"{}\"", declared))?;
if descriptor.is_workspace() {
bail!(
"workspace member \"{}\" is itself a workspace; nested workspaces are not supported",
declared,
);
}
inherit_from_workspace(&mut descriptor, &root_desc);
raw_members.push(Member {
path,
declared: declared.clone(),
descriptor,
workspace_deps: Vec::new(),
});
}
let canon: Vec<PathBuf> = raw_members
.iter()
.map(|m| m.path.canonicalize().unwrap_or_else(|_| m.path.clone()))
.collect();
let mut edges: Vec<Vec<usize>> = vec![Vec::new(); raw_members.len()];
for (i, m) in raw_members.iter().enumerate() {
for (label, dep) in &m.descriptor.workspace_dependencies {
let target = m.path.join(&dep.path);
let target_canon = target.canonicalize().with_context(|| {
format!(
"workspace-dep \"{}\" of \"{}\" points to {} which does not exist",
label, m.declared, target.display(),
)
})?;
let target_idx = canon.iter().position(|c| c == &target_canon).ok_or_else(|| {
anyhow::anyhow!(
"workspace-dep \"{}\" of \"{}\" → {} is not a workspace member; add it to [workspace.members] in {}",
label, m.declared, target.display(), workspace_root.join("Curie.toml").display(),
)
})?;
if target_idx == i {
bail!(
"workspace-dep \"{}\" of \"{}\" points at itself",
label, m.declared,
);
}
edges[i].push(target_idx);
}
}
let order = topo_sort(raw_members.len(), &edges).map_err(|cycle| {
let chain = cycle
.iter()
.map(|&i| raw_members[i].declared.as_str())
.collect::<Vec<_>>()
.join(" -> ");
anyhow::anyhow!("workspace-dependency cycle detected: {}", chain)
})?;
let mut old_to_new = vec![0usize; raw_members.len()];
for (new_idx, &old_idx) in order.iter().enumerate() {
old_to_new[old_idx] = new_idx;
}
let mut slots: Vec<Option<Member>> = raw_members.into_iter().map(Some).collect();
let mut members: Vec<Member> = Vec::with_capacity(order.len());
for &old_idx in &order {
let mut m = slots[old_idx].take().expect("each slot drained exactly once");
m.workspace_deps = edges[old_idx].iter().map(|&old| old_to_new[old]).collect();
members.push(m);
}
Ok(Workspace {
root: workspace_root.to_path_buf(),
members,
})
}
fn inherit_from_workspace(member: &mut Descriptor, ws: &Descriptor) {
if member.java.source_compatibility.is_none() {
member.java.source_compatibility = ws.java.source_compatibility.clone();
}
if member.test.junit_platform_version.is_none() {
member.test.junit_platform_version = ws.test.junit_platform_version.clone();
}
if member.kotlin.version.is_none() {
member.kotlin.version = ws.kotlin.version.clone();
}
if !ws.repositories.is_empty() {
let mut combined = ws.repositories.clone();
combined.append(&mut member.repositories);
member.repositories = combined;
}
member.inherited_bom_imports = ws.bom_imports.clone();
member.inherited_test_bom_imports = ws.test_bom_imports.clone();
member.inherited_annotation_processors = ws.annotation_processors.clone();
member.inherited_test_annotation_processors = ws.test_annotation_processors.clone();
member.inherited_annotation_processor_options = ws.annotation_processor_options.clone();
member.inherited_test_annotation_processor_options =
ws.test_annotation_processor_options.clone();
}
fn topo_sort(n: usize, edges: &[Vec<usize>]) -> std::result::Result<Vec<usize>, Vec<usize>> {
let mut out_degree: Vec<usize> = edges.iter().map(|e| e.len()).collect();
let mut reverse: Vec<Vec<usize>> = vec![Vec::new(); n];
for (v, deps) in edges.iter().enumerate() {
for &w in deps {
reverse[w].push(v);
}
}
let mut queue: VecDeque<usize> = (0..n).filter(|&v| out_degree[v] == 0).collect();
let mut order: Vec<usize> = Vec::with_capacity(n);
while let Some(v) = queue.pop_front() {
order.push(v);
for &dependent in &reverse[v] {
out_degree[dependent] -= 1;
if out_degree[dependent] == 0 {
queue.push_back(dependent);
}
}
}
if order.len() < n {
let leftover: Vec<usize> = (0..n).filter(|v| !order.contains(v)).collect();
Err(leftover)
} else {
Ok(order)
}
}
pub fn list(workspace_root: &Path) -> Result<()> {
let ws = load(workspace_root)?;
println!(
"Workspace {} ({} member{})",
ws.root.display(),
ws.members.len(),
if ws.members.len() == 1 { "" } else { "s" },
);
let name_w = ws.members.iter().map(|m| m.declared.len()).max().unwrap_or(0);
for m in &ws.members {
println!(
" {:<width$} {:<11} v{}",
m.declared,
m.descriptor.kind_label(),
m.descriptor.buildable_version(),
width = name_w,
);
}
Ok(())
}
struct MemberArtifact {
classes_dir: PathBuf,
classpath_contribution: Vec<PathBuf>,
}
fn collect_dep_classpath(
deps: &[usize],
artifacts: &std::collections::HashMap<usize, MemberArtifact>,
) -> Vec<PathBuf> {
let mut cp: Vec<PathBuf> = Vec::new();
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for &i in deps {
let a = artifacts
.get(&i)
.expect("subset must include all transitive workspace_deps of every member it builds");
if seen.insert(a.classes_dir.clone()) {
cp.push(a.classes_dir.clone());
}
for entry in &a.classpath_contribution {
if seen.insert(entry.clone()) {
cp.push(entry.clone());
}
}
}
cp
}
fn transitive_closure(ws: &Workspace, target: usize) -> Vec<usize> {
let mut included: std::collections::HashSet<usize> = std::collections::HashSet::new();
let mut stack: Vec<usize> = vec![target];
while let Some(i) = stack.pop() {
if included.insert(i) {
for &dep in &ws.members[i].workspace_deps {
stack.push(dep);
}
}
}
(0..ws.members.len()).filter(|i| included.contains(i)).collect()
}
fn fan_out<F>(ws: &Workspace, action: &str, subset: &[usize], mut run: F) -> Result<()>
where
F: FnMut(&Member, &[PathBuf]) -> Result<Vec<PathBuf>>,
{
let n = subset.len();
println!(
"Workspace {} {} ({} member{})",
ws.root.display(),
action,
n,
if n == 1 { "" } else { "s" },
);
println!();
let mut artifacts: std::collections::HashMap<usize, MemberArtifact> =
std::collections::HashMap::with_capacity(n);
for (pos, &idx) in subset.iter().enumerate() {
let m = &ws.members[idx];
println!("[{}/{}] {}", pos + 1, n, m.declared);
let extra_cp = collect_dep_classpath(&m.workspace_deps, &artifacts);
let own_dep_jars = run(m, &extra_cp)
.with_context(|| format!("workspace member \"{}\" failed", m.declared))?;
let classes_dir = m.path.join("target").join("classes");
let mut contribution = extra_cp; for j in own_dep_jars {
contribution.push(j);
}
artifacts.insert(idx, MemberArtifact { classes_dir, classpath_contribution: contribution });
println!();
}
Ok(())
}
fn fan_out_all<F>(workspace_root: &Path, action: &str, run: F) -> Result<()>
where
F: FnMut(&Member, &[PathBuf]) -> Result<Vec<PathBuf>>,
{
let ws = load(workspace_root)?;
let all: Vec<usize> = (0..ws.members.len()).collect();
fan_out(&ws, action, &all, run)
}
fn fan_out_one<F>(workspace_root: &Path, target: usize, action: &str, run: F) -> Result<()>
where
F: FnMut(&Member, &[PathBuf]) -> Result<Vec<PathBuf>>,
{
let ws = load(workspace_root)?;
let subset = transitive_closure(&ws, target);
fan_out(&ws, action, &subset, run)
}
pub fn build_all(workspace_root: &Path, opts: build::BuildOptions) -> Result<()> {
fan_out_all(workspace_root, "build", |m, extra_cp| {
let output = build::build_with_desc(&m.path, &m.descriptor, opts, extra_cp)?;
Ok(output.dep_jars)
})
}
pub fn build_one(
workspace_root: &Path,
member_index: usize,
opts: build::BuildOptions,
) -> Result<()> {
fan_out_one(workspace_root, member_index, "build", |m, extra_cp| {
let output = build::build_with_desc(&m.path, &m.descriptor, opts, extra_cp)?;
Ok(output.dep_jars)
})
}
pub fn test_all(workspace_root: &Path, filter: Option<&str>, offline: bool) -> Result<()> {
fan_out_all(workspace_root, "test", |m, extra_cp| {
test_one_member(m, filter, offline, extra_cp)
})
}
pub fn test_one(
workspace_root: &Path,
member_index: usize,
filter: Option<&str>,
offline: bool,
) -> Result<()> {
fan_out_one(workspace_root, member_index, "test", |m, extra_cp| {
test_one_member(m, filter, offline, extra_cp)
})
}
fn test_one_member(
m: &Member,
filter: Option<&str>,
offline: bool,
extra_cp: &[PathBuf],
) -> Result<Vec<PathBuf>> {
println!(
"Testing {} v{}",
m.descriptor.buildable_name(),
m.descriptor.buildable_version(),
);
let compiled = compile::compile(&m.path, &m.descriptor, offline, extra_cp)?;
test::run_tests(
&m.path,
&m.descriptor,
&compiled.classes_dir,
&compiled.dep_jars,
&compiled.kotlin_stdlib_jars,
compiled.resources_dir.as_deref(),
compiled.test_resources_dir.as_deref(),
filter,
offline,
extra_cp,
)?;
Ok(compiled.dep_jars)
}
pub fn run_one(
workspace_root: &Path,
member_index: usize,
opts: run::RunOptions,
args: &[String],
) -> Result<()> {
let ws = load(workspace_root)?;
let target = &ws.members[member_index];
if target.descriptor.is_library() {
bail!("`curie run` is not supported for library projects");
}
if !opts.no_docker && descriptor::docker_enabled(&target.path, &target.descriptor) {
bail!(
"Docker support for `curie run` on a workspace member with \
[workspace-dependencies] is not yet implemented. Re-run \
with --no-docker, or remove [workspace-dependencies] and \
use the standalone path."
);
}
let subset = transitive_closure(&ws, member_index);
let build_opts = build::BuildOptions { no_docker: opts.no_docker, offline: opts.offline };
let n = subset.len();
println!(
"Workspace {} run ({} member{} to build)",
ws.root.display(),
n,
if n == 1 { "" } else { "s" },
);
println!();
let mut artifacts: std::collections::HashMap<usize, MemberArtifact> =
std::collections::HashMap::with_capacity(n);
let mut outputs: std::collections::HashMap<usize, build::BuildOutput> =
std::collections::HashMap::with_capacity(n);
for (pos, &idx) in subset.iter().enumerate() {
let m = &ws.members[idx];
println!("[{}/{}] {}", pos + 1, n, m.declared);
let extra_cp = collect_dep_classpath(&m.workspace_deps, &artifacts);
let output = build::build_with_desc(&m.path, &m.descriptor, build_opts, &extra_cp)
.with_context(|| format!("workspace member \"{}\" failed", m.declared))?;
let classes_dir = m.path.join("target").join("classes");
let mut contribution = extra_cp;
for j in output.dep_jars.iter().cloned() {
contribution.push(j);
}
artifacts.insert(idx, MemberArtifact { classes_dir, classpath_contribution: contribution });
outputs.insert(idx, output);
println!();
}
let target_output = &outputs[&member_index];
let main_class = target_output
.main_class
.as_deref()
.expect("application member should have resolved main_class after build");
println!(
"Running {} v{}",
target.descriptor.buildable_name(),
target.descriptor.buildable_version(),
);
println!();
let mut runtime_cp: Vec<PathBuf> = Vec::new();
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
let push = |cp: &mut Vec<PathBuf>, seen: &mut std::collections::HashSet<PathBuf>, p: PathBuf| {
if seen.insert(p.clone()) {
cp.push(p);
}
};
push(&mut runtime_cp, &mut seen, target_output.jar.clone());
if let Some(rd) = &target_output.resources_dir {
if rd.exists() {
push(&mut runtime_cp, &mut seen, rd.clone());
}
}
for j in &target_output.dep_jars {
push(&mut runtime_cp, &mut seen, j.clone());
}
for &idx in &subset {
if idx == member_index {
continue;
}
let out = &outputs[&idx];
push(&mut runtime_cp, &mut seen, out.jar.clone());
for j in &out.dep_jars {
push(&mut runtime_cp, &mut seen, j.clone());
}
}
let mut java = Command::new("java");
java.arg("-cp").arg(jar::classpath_string(&runtime_cp));
java.arg(main_class);
for a in args {
java.arg(a);
}
let status = java
.status()
.context("failed to invoke java — is a JRE installed?")?;
if !status.success() {
let code = status.code().unwrap_or(1);
std::process::exit(code);
}
Ok(())
}
pub fn clean_all(workspace_root: &Path) -> Result<()> {
fan_out_all(workspace_root, "clean", |m, _extra_cp| {
build::clean(&m.path)?;
Ok(Vec::new())
})
}
pub fn fmt_all(workspace_root: &Path, check_only: bool, offline: bool) -> Result<()> {
let ws = load(workspace_root)?;
let n = ws.members.len();
let pjf_jars = fmt::resolve_pjf(offline)?;
let mp = MultiProgress::new();
let summary = mp.add(ProgressBar::new(n as u64));
summary.set_style(
ProgressStyle::with_template(
" Formatting [{bar:40.cyan/blue}] {pos}/{len}",
)
.unwrap()
.progress_chars("=>-"),
);
let spinner_style = ProgressStyle::with_template(" {spinner} {msg}")
.unwrap()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ");
let spinners: Vec<ProgressBar> = ws
.members
.iter()
.map(|m| {
let sp = mp.add(ProgressBar::new_spinner());
sp.set_style(spinner_style.clone());
sp.set_message(m.declared.clone());
sp
})
.collect();
let pjf_jars_ref = &pjf_jars;
let errors: Vec<String> = std::thread::scope(|s| {
let handles: Vec<_> = ws
.members
.iter()
.zip(spinners.iter())
.map(|(m, sp)| {
let path = &m.path;
let summary = summary.clone();
s.spawn(move || {
sp.enable_steady_tick(std::time::Duration::from_millis(80));
let result = fmt::run_fmt_with_jars(path, check_only, pjf_jars_ref);
match &result {
Ok(_) => sp.finish_and_clear(),
Err(_) => {
sp.set_style(
ProgressStyle::with_template(" {msg}")
.unwrap(),
);
sp.finish_with_message(
format!("✗ {}", m.declared),
);
}
}
summary.inc(1);
result
})
})
.collect();
handles
.into_iter()
.filter_map(|h| h.join().expect("fmt thread panicked").err())
.map(|e| format!("{:#}", e))
.collect()
});
mp.clear().ok();
if errors.is_empty() {
Ok(())
} else {
anyhow::bail!("{}", errors.join("\n"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_workspace(members: &[&str]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let members_toml = members
.iter()
.map(|m| format!("\"{}\"", m))
.collect::<Vec<_>>()
.join(", ");
std::fs::write(
dir.path().join("Curie.toml"),
format!("[workspace]\nmembers = [{members_toml}]\n"),
)
.unwrap();
for m in members {
let mpath = dir.path().join(m);
std::fs::create_dir_all(&mpath).unwrap();
std::fs::write(
mpath.join("Curie.toml"),
format!("[application]\nname = \"{m}\"\nversion = \"0.1.0\"\nmainClass = \"X\"\n"),
)
.unwrap();
}
dir
}
#[test]
fn load_workspace_with_two_members() {
let dir = make_workspace(&["a", "b"]);
let ws = load(dir.path()).unwrap();
assert_eq!(ws.members.len(), 2);
assert_eq!(ws.members[0].declared, "a");
assert_eq!(ws.members[1].declared, "b");
assert_eq!(ws.members[0].descriptor.project_name(), Some("a"));
}
#[test]
fn load_workspace_missing_member_fails() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[workspace]\nmembers = [\"ghost\"]\n",
)
.unwrap();
let err = load(dir.path()).unwrap_err().to_string();
assert!(err.contains("ghost"), "got: {err}");
}
#[test]
fn load_nested_workspace_fails() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[workspace]\nmembers = [\"inner\"]\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("inner")).unwrap();
std::fs::write(
dir.path().join("inner").join("Curie.toml"),
"[workspace]\nmembers = []\n",
)
.unwrap();
let err = load(dir.path()).unwrap_err().to_string();
assert!(err.contains("nested"), "got: {err}");
}
#[test]
fn load_non_workspace_fails() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"x\"\nversion = \"1.0\"\nmainClass = \"X\"\n",
)
.unwrap();
let err = load(dir.path()).unwrap_err().to_string();
assert!(err.contains("not a workspace"), "got: {err}");
}
#[test]
fn topo_sort_no_edges_is_input_order() {
let order = topo_sort(3, &[vec![], vec![], vec![]]).unwrap();
assert_eq!(order, vec![0, 1, 2]);
}
#[test]
fn topo_sort_linear_chain() {
let order = topo_sort(3, &[vec![1], vec![2], vec![]]).unwrap();
assert_eq!(order, vec![2, 1, 0]);
}
#[test]
fn topo_sort_diamond() {
let order = topo_sort(4, &[vec![1, 2], vec![3], vec![3], vec![]]).unwrap();
assert_eq!(order[0], 3);
assert_eq!(order[3], 0);
}
#[test]
fn topo_sort_cycle_is_reported() {
let err = topo_sort(2, &[vec![1], vec![0]]).unwrap_err();
assert_eq!(err.len(), 2);
assert!(err.contains(&0) && err.contains(&1));
}
fn make_ws_with_deps(specs: &[(&str, &[(&str, &str)])]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let members_toml = specs
.iter()
.map(|(name, _)| format!("\"{}\"", name))
.collect::<Vec<_>>()
.join(", ");
std::fs::write(
dir.path().join("Curie.toml"),
format!("[workspace]\nmembers = [{members_toml}]\n"),
)
.unwrap();
for (name, deps) in specs {
let mpath = dir.path().join(name);
std::fs::create_dir_all(&mpath).unwrap();
let mut toml = format!("[library]\nname = \"{name}\"\nversion = \"0.1.0\"\n");
if !deps.is_empty() {
toml.push_str("[workspace-dependencies]\n");
for (label, path) in *deps {
toml.push_str(&format!("{label} = {{ path = \"{path}\" }}\n"));
}
}
std::fs::write(mpath.join("Curie.toml"), toml).unwrap();
}
dir
}
#[test]
fn workspace_deps_drive_topo_order() {
let dir = make_ws_with_deps(&[
("app", &[("lib", "../lib")]),
("lib", &[]),
]);
let ws = load(dir.path()).unwrap();
let names: Vec<&str> = ws.members.iter().map(|m| m.declared.as_str()).collect();
assert_eq!(names, vec!["lib", "app"]);
assert_eq!(ws.members[1].workspace_deps, vec![0]);
assert_eq!(ws.members[0].workspace_deps, Vec::<usize>::new());
}
#[test]
fn workspace_dep_to_non_member_fails() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[workspace]\nmembers = [\"app\"]\n",
)
.unwrap();
let apath = dir.path().join("app");
std::fs::create_dir_all(&apath).unwrap();
let lib_path = dir.path().join("lib");
std::fs::create_dir_all(&lib_path).unwrap();
std::fs::write(
lib_path.join("Curie.toml"),
"[library]\nname = \"lib\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::write(
apath.join("Curie.toml"),
"[application]\nname = \"app\"\nversion = \"0.1.0\"\nmainClass = \"X\"\n\
[workspace-dependencies]\nlib = { path = \"../lib\" }\n",
)
.unwrap();
let err = load(dir.path()).unwrap_err().to_string();
assert!(err.contains("not a workspace member"), "got: {err}");
}
#[test]
fn workspace_dep_to_missing_path_fails() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[workspace]\nmembers = [\"app\"]\n",
)
.unwrap();
let apath = dir.path().join("app");
std::fs::create_dir_all(&apath).unwrap();
std::fs::write(
apath.join("Curie.toml"),
"[application]\nname = \"app\"\nversion = \"0.1.0\"\nmainClass = \"X\"\n\
[workspace-dependencies]\nghost = { path = \"../ghost\" }\n",
)
.unwrap();
let err = load(dir.path()).unwrap_err().to_string();
assert!(err.contains("does not exist"), "got: {err}");
}
#[test]
fn workspace_dep_cycle_is_rejected() {
let dir = make_ws_with_deps(&[
("a", &[("b", "../b")]),
("b", &[("a", "../a")]),
]);
let err = load(dir.path()).unwrap_err().to_string();
assert!(err.contains("cycle"), "got: {err}");
}
fn load_ws_with_content(ws_toml: &str, members: &[(&str, &str)]) -> Result<Workspace> {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Curie.toml"), ws_toml).unwrap();
for (name, content) in members {
let mpath = dir.path().join(name);
std::fs::create_dir_all(&mpath).unwrap();
std::fs::write(mpath.join("Curie.toml"), content).unwrap();
}
let result = load(dir.path());
std::mem::forget(dir);
result
}
#[test]
fn java_inherits_from_workspace_when_member_silent() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n[java]\nsourceCompatibility = \"17\"\n",
&[("a", "[library]\nname = \"a\"\nversion = \"0.1.0\"\n")],
).unwrap();
assert_eq!(ws.members[0].descriptor.java.effective(), "17");
}
#[test]
fn java_member_value_overrides_workspace() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n[java]\nsourceCompatibility = \"17\"\n",
&[("a", "[library]\nname = \"a\"\nversion = \"0.1.0\"\n[java]\nsourceCompatibility = \"21\"\n")],
).unwrap();
assert_eq!(ws.members[0].descriptor.java.effective(), "21");
}
#[test]
fn java_falls_back_to_default_when_neither_sets_it() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n",
&[("a", "[library]\nname = \"a\"\nversion = \"0.1.0\"\n")],
).unwrap();
assert_eq!(ws.members[0].descriptor.java.effective(), "21");
}
#[test]
fn bom_imports_inherit_into_inherited_field() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n\
[bom-imports]\n\"org.x:bom\" = \"1.0\"\n",
&[("a", "[library]\nname = \"a\"\nversion = \"0.1.0\"\n")],
).unwrap();
let d = &ws.members[0].descriptor;
assert_eq!(d.inherited_bom_imports.get("org.x:bom").map(String::as_str), Some("1.0"));
assert!(d.bom_imports.is_empty());
let gavs = d.prod_bom_gavs().unwrap();
assert_eq!(gavs.len(), 1);
assert_eq!(gavs[0].to_string(), "org.x:bom:1.0");
}
#[test]
fn member_bom_appears_after_workspace_bom_in_gav_order() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n\
[bom-imports]\n\"org.x:bom\" = \"1.0\"\n",
&[("a", "[library]\nname = \"a\"\nversion = \"0.1.0\"\n\
[bom-imports]\n\"org.x:bom\" = \"2.0\"\n")],
).unwrap();
let gavs = ws.members[0].descriptor.prod_bom_gavs().unwrap();
assert_eq!(gavs.len(), 2);
assert_eq!(gavs[0].to_string(), "org.x:bom:1.0", "inherited (ws) first");
assert_eq!(gavs[1].to_string(), "org.x:bom:2.0", "member's own second");
}
#[test]
fn test_bom_gavs_layer_inherited_and_own() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n\
[bom-imports]\n\"ws:prod\" = \"1\"\n\
[test-bom-imports]\n\"ws:test\" = \"1\"\n",
&[("a", "[library]\nname = \"a\"\nversion = \"0.1.0\"\n\
[bom-imports]\n\"own:prod\" = \"1\"\n\
[test-bom-imports]\n\"own:test\" = \"1\"\n")],
).unwrap();
let gavs: Vec<String> = ws.members[0]
.descriptor
.test_bom_gavs()
.unwrap()
.iter()
.map(|g| g.to_string())
.collect();
assert_eq!(gavs, vec!["ws:prod:1", "own:prod:1", "ws:test:1", "own:test:1"]);
}
#[test]
fn discover_workspace_root() {
let dir = make_ws_with_deps(&[("a", &[])]);
match discover(dir.path()).unwrap() {
WorkspaceContext::WorkspaceRoot(p) => {
assert_eq!(p.canonicalize().unwrap(), dir.path().canonicalize().unwrap());
}
other => panic!("expected WorkspaceRoot, got {:?}", other),
}
}
#[test]
fn discover_workspace_member_from_child_dir() {
let dir = make_ws_with_deps(&[("a", &[]), ("b", &[("a", "../a")])]);
let b = dir.path().join("b");
match discover(&b).unwrap() {
WorkspaceContext::WorkspaceMember { workspace_root, member_index } => {
assert_eq!(
workspace_root.canonicalize().unwrap(),
dir.path().canonicalize().unwrap(),
);
assert_eq!(member_index, 1);
}
other => panic!("expected WorkspaceMember, got {:?}", other),
}
}
#[test]
fn discover_standalone_when_no_workspace_above() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[application]\nname = \"alone\"\nversion = \"1.0\"\nmainClass = \"X\"\n",
)
.unwrap();
match discover(dir.path()).unwrap() {
WorkspaceContext::Standalone(p) => {
assert_eq!(p.canonicalize().unwrap(), dir.path().canonicalize().unwrap());
}
other => panic!("expected Standalone, got {:?}", other),
}
}
#[test]
fn discover_standalone_when_sibling_workspace_does_not_list_us() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Curie.toml"),
"[workspace]\nmembers = [\"a\"]\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("a")).unwrap();
std::fs::write(
dir.path().join("a").join("Curie.toml"),
"[application]\nname = \"a\"\nversion = \"0.1.0\"\nmainClass = \"X\"\n",
)
.unwrap();
let b = dir.path().join("b");
std::fs::create_dir_all(&b).unwrap();
std::fs::write(
b.join("Curie.toml"),
"[application]\nname = \"b\"\nversion = \"0.1.0\"\nmainClass = \"X\"\n",
)
.unwrap();
match discover(&b).unwrap() {
WorkspaceContext::Standalone(_) => {}
other => panic!("expected Standalone for unlisted sibling, got {:?}", other),
}
}
#[test]
fn repositories_inherit_prepended() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n\
[[repositories]]\nname = \"ws-repo\"\nurl = \"https://ws.example.com\"\n",
&[("a", "[library]\nname = \"a\"\nversion = \"0.1.0\"\n\
[[repositories]]\nname = \"own-repo\"\nurl = \"https://own.example.com\"\n")],
).unwrap();
let repos = &ws.members[0].descriptor.repositories;
assert_eq!(repos.len(), 2);
assert_eq!(repos[0].name, "ws-repo");
assert_eq!(repos[1].name, "own-repo");
}
#[test]
fn workspace_annotation_processors_flow_to_member() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n\
[annotation-processors]\n\
\"org.projectlombok:lombok\" = { version = \"1.18.30\", on-compile-classpath = true }\n",
&[("a", "[application]\nname = \"a\"\nversion = \"0.1.0\"\nmainClass = \"X\"\n")],
).unwrap();
let pairs = ws.members[0].descriptor.ap_pairs();
assert_eq!(pairs, vec![("org.projectlombok:lombok", "1.18.30")]);
let on_cp = ws.members[0].descriptor.ap_on_compile_classpath_coords();
assert_eq!(on_cp, vec!["org.projectlombok:lombok"]);
}
#[test]
fn member_annotation_processor_overrides_workspace() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n\
[annotation-processors]\n\
\"shared:proc\" = \"1.0\"\n",
&[("a", "[application]\nname = \"a\"\nversion = \"0.1.0\"\nmainClass = \"X\"\n\
[annotation-processors]\n\"shared:proc\" = \"2.0\"\n")],
).unwrap();
let pairs = ws.members[0].descriptor.ap_pairs();
assert_eq!(pairs, vec![("shared:proc", "2.0")]);
}
#[test]
fn workspace_ap_options_flow_to_member_with_member_override() {
let ws = load_ws_with_content(
"[workspace]\nmembers = [\"a\"]\n\
[annotation-processor-options.dagger]\n\
fastInit = \"disabled\"\nformatGeneratedSource = \"disabled\"\n",
&[("a", "[application]\nname = \"a\"\nversion = \"0.1.0\"\nmainClass = \"X\"\n\
[annotation-processor-options.dagger]\nfastInit = \"enabled\"\n")],
).unwrap();
let flat = ws.members[0].descriptor.flat_ap_options();
assert_eq!(
flat,
vec![
("dagger.fastInit".to_string(), "enabled".to_string()),
("dagger.formatGeneratedSource".to_string(), "disabled".to_string()),
],
);
}
#[test]
fn fmt_all_no_java_files_succeeds() {
let dir = make_workspace(&["alpha", "beta", "gamma"]);
fmt_all(dir.path(), false, false).expect("fmt_all should succeed on empty members");
}
#[test]
fn fmt_all_reports_all_member_errors() {
let dir = make_workspace(&["m1", "m2"]);
let result = fmt_all(dir.path(), true, false);
assert!(result.is_ok(), "unexpected error: {:?}", result);
}
}