use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{Context, Result, bail};
use inquire::{MultiSelect, Select};
use crate::catalog::artifacts;
use crate::catalog::capabilities;
use crate::catalog::tool_catalog::{self, ToolKind};
use crate::catalog::tool_settings;
use crate::catalog::wally_packages::{self, Category, PackageSpec, companions_for};
use crate::commands::provision;
use crate::config::{GlobalConfig, PackageWorkflow, Setups, project_file, project_template};
use crate::graph::{Node, ProjectGraph, TestRunner};
use crate::steps::{
asphalt, blender, figma, git, gitattributes, gitignore, jest, modules, quality, rojo,
studio_plugin, testez, toolchain, tungsten, vscode, wally,
};
use crate::ui;
pub fn run(
name: &str,
reconfigure: bool,
like: Option<&str>,
save_setup: Option<&str>,
) -> Result<()> {
let mut config = GlobalConfig::load()?;
let (project_dir, project_template) = prepare_project(&config, name)?;
let saved = match like {
Some(setup) => Some(load_setup(setup)?),
None => None,
};
if reconfigure || !config.machine_configured() {
if !config.machine_configured() {
println!("Setting your machine up first - this only happens once.\n");
}
provision::run(&mut config)?;
} else {
ui::ok(&format!("machine ready: {}", config.machine_summary()));
ui::detail("rproj setup to re-check, or rproj new --reconfigure to change");
}
ui::section(&format!("Scaffolding {name}"));
ui::detail(&project_dir.display().to_string());
let mut graph = match saved {
Some((setup_name, setup)) => {
ui::ok(&format!(
"using saved setup `{setup_name}`: {}",
if setup.packages.is_empty() {
"no packages".to_string()
} else {
setup.packages.join(", ")
}
));
ProjectGraph {
mode: format!("like:{setup_name}"),
..setup
}
}
None => ask_for_graph()?,
};
apply_derived_packages(&mut graph);
let (apps, extensions) = (
config.selected_system_apps.clone(),
config.selected_vscode_extensions.clone(),
);
let planned = loop {
let planned = graph.plan(&apps, &extensions);
match confirm_plan(name, &graph, &planned)? {
Outcome::Create => break planned,
Outcome::Customize => {
graph.dropped =
customize_plan(&graph.full_plan(&apps, &extensions), &graph.dropped)?;
}
Outcome::Change => {
revise(&mut graph)?;
apply_derived_packages(&mut graph);
}
Outcome::Cancel => {
crate::diagnostics::event("new.cancel", "nothing created");
println!("\nNothing created.");
return Ok(());
}
}
};
execute_confirmed(
name,
&project_dir,
&graph,
&planned,
project_template.as_ref(),
&mut config,
save_setup.map(SetupSave::Replace),
)
}
pub(super) fn prepare_project(
config: &GlobalConfig,
name: &str,
) -> Result<(std::path::PathBuf, Option<serde_json::Value>)> {
let project_dir = config.projects_root()?.join(name);
crate::diagnostics::event("new.destination", project_dir.display().to_string());
if project_dir.exists() {
bail!("{} already exists", project_dir.display());
}
let template = project_template::load()?;
if let Some(template) = &template {
rojo::validate_template_with_rojo(template).context(
"the saved project template is invalid; run `rproj configure project` to repair or reset it",
)?;
}
Ok((project_dir, template))
}
pub(super) enum SetupSave<'a> {
Replace(&'a str),
New(&'a str),
}
pub(super) fn execute_confirmed(
name: &str,
project_dir: &Path,
graph: &ProjectGraph,
planned: &[artifacts::Planned],
project_template: Option<&serde_json::Value>,
config: &mut GlobalConfig,
save_setup: Option<SetupSave<'_>>,
) -> Result<()> {
crate::interrupt::check()?;
create_project_dir(project_dir)?;
let result = (|| {
let packages = graph.package_set();
let package_workflow = graph.package_workflow;
let project_tools = graph.tools();
let test_runner = graph.test_runner();
let chosen_artifacts: Vec<String> = planned.iter().map(|p| p.key.to_string()).collect();
scaffold(
project_dir,
name,
ScaffoldOptions {
packages: &packages,
package_workflow,
project_tools: &project_tools,
test_runner,
jest_backend: graph.jest_backend(),
chosen_artifacts: &chosen_artifacts,
project_template,
},
)?;
crate::interrupt::check()?;
let writes = |key: &str| chosen_artifacts.iter().any(|k| k == key);
if writes("rproj.toml") {
project_file::save_to(graph, project_dir)?;
} else {
ui::skip("rproj.toml not written, so `rproj upgrade` won't know this project");
}
if let Some(save) = save_setup {
crate::interrupt::check()?;
let setup_name = match save {
SetupSave::Replace(name) => {
Setups::save(graph, name)?;
name
}
SetupSave::New(name) => {
Setups::save_new(graph, name)?;
name
}
};
ui::ok(&format!(
"saved setup `{setup_name}` - reuse with `rproj new <name> --like {setup_name}`"
));
}
if test_runner == Some(TestRunner::JestRoblox)
&& graph.jest_backend() == crate::graph::JestBackend::Studio
{
crate::interrupt::check()?;
if !config
.selected_studio_plugins
.iter()
.any(|key| key == "jest-roblox-plugin")
{
config
.selected_studio_plugins
.push("jest-roblox-plugin".into());
config.save()?;
}
if let Err(error) = refresh_jest_plugin() {
ui::warn(&format!(
"Jest Roblox Studio runner was not updated - {error}. Run `rproj setup` to retry"
));
}
}
crate::interrupt::check()?;
let (party, folder, eye, gate, book) = ("🎉 ", "📁", "👀", "🧪", "📖");
println!(
"\n{party}{name} is ready.\n\n\
\x20 {folder} cd {}\n\
\x20 {eye} rproj watch start the dev loop (Rojo sourcemap watcher)\n\
\x20 {gate} lute run check run the quality gate (types, lint, format)\n\
\x20 {book} rproj info <tool> what a tool does, and the commands to use it\n",
project_dir.display()
);
Ok(())
})();
if result.is_err() {
ui::warn(&format!(
"Project creation did not finish. Files remain at {} and may be incomplete. Inspect this directory before retrying; rproj will not overwrite it.",
project_dir.display()
));
}
result
}
fn create_project_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::create_dir(path)
.with_context(|| format!("failed to create new project at {}", path.display()))
}
fn refresh_jest_plugin() -> Result<()> {
let entry = tool_catalog::find("jest-roblox-plugin")
.context("Jest Roblox Studio runner is missing from the tool catalog")?;
let ToolKind::StudioPlugin {
github_repo,
asset_suffix,
} = entry.kind
else {
bail!("Jest Roblox Studio runner has an invalid catalog installer");
};
studio_plugin::refresh_from_latest_release(entry.key, github_repo, asset_suffix)
}
fn ask_for_graph() -> Result<ProjectGraph> {
let mut graph = ProjectGraph::default();
ask_node(&mut graph, Node::Strategy)?;
ask_node(&mut graph, Node::Packages)?;
ask_node(&mut graph, Node::Capabilities)?;
Ok(graph)
}
fn ask_node(graph: &mut ProjectGraph, node: Node) -> Result<()> {
crate::diagnostics::event("prompt.node", node.label());
graph.invalidate(node);
match node {
Node::Strategy => {
graph.package_workflow = pick_strategy()?;
if graph.remove_incompatible_testing() {
ui::warn(
"Jest Roblox requires Wally, so the previous testing choice no longer applies",
);
let choice = Select::new(
"Testing:",
owned(&[
"testez - keep testing with TestEZ",
"none - disable testing",
]),
)
.with_formatter(&ui::compact_select_answer)
.prompt()?;
if ui::option_key(&choice) == "testez" {
graph.choose("test", Some("testez"));
}
}
}
Node::Packages => {
let (mode, packages) = match graph.package_workflow {
PackageWorkflow::None => ("none", BTreeSet::new()),
workflow => pick_composition(workflow)?,
};
let (workflow, packages) = reconcile_strategy(graph.package_workflow, packages)?;
graph.mode = mode.to_string();
graph.package_workflow = workflow;
graph.packages = resolve_dependencies(workflow, packages)
.into_iter()
.collect();
}
Node::Capabilities => {
graph.capabilities.clear();
for (key, implementation) in pick_capabilities(graph.package_workflow)? {
graph.choose(&key, implementation.as_deref());
}
}
Node::Files => {}
}
Ok(())
}
fn revise(graph: &mut ProjectGraph) -> Result<()> {
let editable: Vec<Node> = Node::ALL
.into_iter()
.filter(|n| *n != Node::Files)
.collect();
let options: Vec<String> = editable
.iter()
.map(|node| ui::option_line(node.label(), &describe_node(graph, *node), "change"))
.collect();
let picked = Select::new("Change which answer?", options)
.with_formatter(&ui::compact_select_answer)
.prompt()?;
let Some(node) = editable
.iter()
.find(|node| ui::option_is(&picked, node.label()))
else {
return Ok(());
};
let stale: Vec<&str> = node
.invalidates()
.iter()
.filter(|n| **n != Node::Files)
.map(|n| n.label())
.collect();
if !stale.is_empty() {
ui::detail(&format!("changing this re-asks: {}", stale.join(", ")));
}
ask_node(graph, *node)?;
for stale in node.invalidates() {
if *stale != Node::Files {
ask_node(graph, *stale)?;
}
}
Ok(())
}
fn describe_node(graph: &ProjectGraph, node: Node) -> String {
match node {
Node::Strategy => match graph.package_workflow {
PackageWorkflow::Wally => "Wally".to_string(),
PackageWorkflow::GitSubmodules => "git submodules".to_string(),
PackageWorkflow::None => "none".to_string(),
},
Node::Packages => {
if graph.packages.is_empty() {
"none".to_string()
} else {
graph.packages.join(", ")
}
}
Node::Capabilities => {
if graph.capabilities.is_empty() {
"nothing extra".to_string()
} else {
graph.capability_keys().join(", ")
}
}
Node::Files => "the generated files".to_string(),
}
}
fn resolve_dependencies(workflow: PackageWorkflow, packages: BTreeSet<String>) -> BTreeSet<String> {
if workflow != PackageWorkflow::GitSubmodules {
return packages;
}
let resolved = wally_packages::with_dependencies(&packages);
let added: Vec<&str> = resolved
.iter()
.filter(|k| !packages.contains(*k))
.map(String::as_str)
.collect();
if !added.is_empty() {
ui::ok(&format!(
"added required dependencies: {}",
added.join(", ")
));
}
resolved
}
fn pick_strategy() -> Result<PackageWorkflow> {
let choice = Select::new(
"How should this project get its dependencies?",
owned(&[
"wally - Roblox's package manager. Recommended for almost every project.",
"git-submodules - vendor each package's own repo into the project.",
"none - no dependency manager. A tutorial project, or one you'll wire up yourself.",
]),
)
.with_formatter(&ui::compact_select_answer)
.prompt()?;
Ok(match ui::option_key(&choice) {
"git-submodules" => PackageWorkflow::GitSubmodules,
"none" => PackageWorkflow::None,
_ => PackageWorkflow::Wally,
})
}
fn owned(options: &[&str]) -> Vec<String> {
options.iter().map(|s| (*s).to_string()).collect()
}
fn pick_capabilities(workflow: PackageWorkflow) -> Result<Vec<(String, Option<String>)>> {
crate::diagnostics::event("prompt", "project capabilities");
let offerable: Vec<&'static capabilities::Capability> =
capabilities::CAPABILITIES.iter().collect();
let options: Vec<String> = offerable
.iter()
.map(|c| {
let badge = if c.key == "test" && c.needs_an_implementation_prompt(workflow) {
"choose runner"
} else {
c.default_implementation().display
};
ui::option_line(c.key, c.outcome, badge)
})
.collect();
let picked = MultiSelect::new("What should this project do?", options)
.with_help_message(ui::MULTISELECT_HELP)
.with_formatter(&ui::compact_multi_answer)
.prompt()?;
let mut chosen: Vec<(String, Option<String>)> = Vec::new();
for capability in &offerable {
if !picked.iter().any(|p| ui::option_is(p, capability.key)) {
continue;
}
if !capability
.missing_requirements(|key| chosen.iter().any(|(chosen, _)| chosen == key))
.is_empty()
{
ui::skip(&format!(
"{} needs {} - skipping it",
capability.key,
capability.requires.join(", ")
));
continue;
}
let mut implementation = if capability.needs_an_implementation_prompt(workflow) {
let implementations = capability.implementation_choices(workflow);
let options: Vec<String> = implementations
.iter()
.map(|i| ui::option_line(i.key, i.display, capability.key))
.collect();
let picked = Select::new(&format!("{}:", capability.key), options)
.with_formatter(&ui::compact_select_answer)
.prompt()?;
Some(ui::option_key(&picked).to_string())
} else {
capability
.implementation_choices(workflow)
.first()
.map(|implementation| implementation.key.to_string())
};
if implementation
.as_deref()
.is_some_and(|key| capability.needs_jest_backend(key))
{
let backend = Select::new(
"Jest execution:",
vec![
"Local Studio (test before pushing; no CI tests)",
"Open Cloud (tests locally and in CI; Roblox credentials required)",
],
)
.prompt()?;
if backend.starts_with("Open Cloud") {
implementation = Some("jest-roblox-open-cloud".into());
}
}
chosen.push((capability.key.to_string(), implementation));
}
Ok(chosen)
}
fn pick_composition(workflow: PackageWorkflow) -> Result<(&'static str, BTreeSet<String>)> {
let unavailable = unvendorable_keys();
if workflow == PackageWorkflow::GitSubmodules && !unavailable.is_empty() {
ui::detail(&format!(
"Not listed: {}.\nUpstream ships these only through an npm install step, which git\nsubmodules can't reproduce.",
unavailable.join(", ")
));
}
let mode = Select::new(
"How do you want to pick packages?",
owned(&[
"guided - one question per category, with explanations (recommended)",
"expert - one flat list of everything, no hand-holding",
]),
)
.with_formatter(&ui::compact_select_answer)
.prompt()?;
if ui::option_key(&mode) == "guided" {
Ok(("guided", pick_guided(workflow)?))
} else {
Ok(("expert", pick_expert(workflow)?))
}
}
fn capability_owned(key: &str) -> bool {
capabilities::CAPABILITIES
.iter()
.any(|c| c.implementations.iter().any(|i| i.packages.contains(&key)))
}
pub(super) fn apply_derived_packages(graph: &mut ProjectGraph) {
graph.packages.retain(|key| !capability_owned(key));
for key in graph.derived().packages {
if !graph.packages.contains(&key) {
graph.packages.push(key);
}
}
graph.packages.sort();
}
fn unvendorable_keys() -> Vec<&'static str> {
wally_packages::PACKAGES
.iter()
.filter(|p| p.submodule.is_none() && !capability_owned(p.key))
.map(|p| p.key)
.collect()
}
pub(super) fn offerable_package(spec: &PackageSpec, workflow: PackageWorkflow) -> bool {
!capability_owned(spec.key)
&& (workflow != PackageWorkflow::GitSubmodules || spec.submodule.is_some())
}
fn confirm_plan(
name: &str,
graph: &ProjectGraph,
planned: &[artifacts::Planned],
) -> Result<Outcome> {
crate::diagnostics::event(
"new.review",
format!(
"workflow={:?}; packages={:?}; capabilities={:?}; dropped={:?}",
graph.package_workflow, graph.packages, graph.capabilities, graph.dropped
),
);
crate::diagnostics::event("prompt", "Create it?");
println!();
for line in summary_lines(name, graph, planned) {
println!("{line}");
}
println!();
let choice = Select::new(
"Create it?",
owned(&[
"create - go ahead and scaffold this",
"change - go back to an earlier answer",
"customize - drop individual files first",
"cancel - change nothing and exit",
]),
)
.with_formatter(&ui::compact_select_answer)
.prompt()?;
match ui::option_key(&choice) {
"create" => Ok(Outcome::Create),
"change" => Ok(Outcome::Change),
"customize" => Ok(Outcome::Customize),
_ => Ok(Outcome::Cancel),
}
}
#[derive(PartialEq, Eq)]
enum Outcome {
Create,
Change,
Customize,
Cancel,
}
fn customize_plan(full_plan: &[artifacts::Planned], dropped: &[String]) -> Result<Vec<String>> {
crate::diagnostics::event("prompt", "Files to keep");
let droppable: Vec<&artifacts::Planned> = full_plan
.iter()
.filter(|p| artifacts::find(p.key).is_some_and(artifacts::Artifact::droppable))
.collect();
if droppable.is_empty() {
return Ok(Vec::new());
}
let options: Vec<String> = droppable
.iter()
.map(|p| ui::option_line(p.key, &p.reason.describe(), "keep"))
.collect();
let keep_now: Vec<usize> = droppable
.iter()
.enumerate()
.filter(|(_, p)| !dropped.iter().any(|d| d == p.key))
.map(|(i, _)| i)
.collect();
let kept = MultiSelect::new("Files to keep", options)
.with_default(&keep_now)
.with_help_message(ui::MULTISELECT_HELP)
.with_formatter(&ui::compact_multi_answer)
.prompt()?;
Ok(droppable
.iter()
.filter(|p| !kept.iter().any(|k| ui::option_is(k, p.key)))
.map(|p| p.key.to_string())
.collect())
}
pub(super) fn summary_lines(
name: &str,
graph: &ProjectGraph,
planned: &[artifacts::Planned],
) -> Vec<String> {
let packages = &graph.package_set();
let chosen = &graph.choices();
let mut lines = vec![format!(" {name}"), String::new()];
let strategy = match graph.package_workflow {
PackageWorkflow::Wally => "Wally",
PackageWorkflow::GitSubmodules => "git submodules",
PackageWorkflow::None => "none",
};
lines.push(format!(" {:<14}{strategy}", "Dependencies"));
lines.push(format!(
" {:<14}{}",
"Packages",
if packages.is_empty() {
"none".to_string()
} else {
packages.iter().cloned().collect::<Vec<_>>().join(", ")
}
));
let does: Vec<String> = chosen
.iter()
.filter_map(|(key, implementation)| {
let capability = capabilities::find(key)?;
let implementation = implementation
.as_deref()
.and_then(|i| capability.implementation(i))
.unwrap_or_else(|| capability.default_implementation());
Some(format!("{key} ({})", implementation.display))
})
.collect();
lines.push(format!(
" {:<14}{}",
"Does",
if does.is_empty() {
"nothing extra".to_string()
} else {
does.join(", ")
}
));
if graph.test_runner() == Some(TestRunner::JestRoblox) {
lines.push(format!(" {:<14}{}", "Jest execution", match graph.jest_backend() {
crate::graph::JestBackend::Studio => "Local Studio; test before pushing. CI has no test step.",
crate::graph::JestBackend::OpenCloud => "Open Cloud; CI runs tests. Configure the Roblox API key, universe ID and dedicated test place ID.",
}));
}
let artifact_keys: Vec<&str> = planned.iter().map(|entry| entry.key).collect();
let configurable: Vec<_> = tool_settings::CONFIGURABLE_TOOLS
.iter()
.filter(|tool| tool.applies_to(&artifact_keys))
.collect();
if !configurable.is_empty() {
lines.push(String::new());
lines.push(" Configure after creation".to_string());
let width = configurable
.iter()
.map(|tool| tool.key.len())
.max()
.unwrap_or(0);
for tool in configurable {
lines.push(format!(
" rproj configure {:<width$} {}",
tool.key, tool.display_name
));
}
}
lines.push(String::new());
lines.push(" Creates".to_string());
let width = planned.iter().map(|p| p.key.len()).max().unwrap_or(0);
for entry in planned {
lines.push(format!(
" {:<width$} {}",
entry.key,
entry.reason.describe()
));
}
lines
}
fn reconcile_strategy(
workflow: PackageWorkflow,
packages: BTreeSet<String>,
) -> Result<(PackageWorkflow, BTreeSet<String>)> {
if workflow != PackageWorkflow::GitSubmodules || packages.is_empty() {
return Ok((workflow, packages));
}
let blocked = wally_packages::unvendorable_in_closure(&packages);
if blocked.is_empty() {
return Ok((workflow, packages));
}
for (key, pulled_in_by) in &blocked {
match pulled_in_by {
Some(dependent) => ui::warn(&format!(
"{dependent} requires {key}, which upstream only ships through an npm install \
step - git submodules can't reproduce that"
)),
None => ui::warn(&format!(
"{key} only ships through an npm install step upstream, which git submodules \
can't reproduce"
)),
}
}
let choice = Select::new(
"So this selection can't be vendored. What now?",
owned(&[
"wally - switch this project to Wally, which handles every catalog package",
"drop - keep git submodules and remove the packages that can't be vendored",
]),
)
.with_formatter(&ui::compact_select_answer)
.prompt()?;
if ui::option_key(&choice) != "drop" {
return Ok((PackageWorkflow::Wally, packages));
}
let removed: Vec<String> = blocked
.iter()
.flat_map(|(key, via)| [Some((*key).to_string()), via.map(|d| d.to_string())])
.flatten()
.collect();
let kept: BTreeSet<String> = packages
.iter()
.filter(|k| !removed.contains(k))
.cloned()
.collect();
ui::skip(&format!("removed: {}", {
let mut names: Vec<&str> = removed.iter().map(String::as_str).collect();
names.sort_unstable();
names.dedup();
names.join(", ")
}));
Ok((PackageWorkflow::GitSubmodules, kept))
}
fn pick_guided(workflow: PackageWorkflow) -> Result<BTreeSet<String>> {
let mut packages = BTreeSet::new();
for category in Category::ALL {
let choices: Vec<&PackageSpec> = wally_packages::in_category(category)
.filter(|p| p.primary_choice && offerable_package(p, workflow))
.collect();
if choices.is_empty() {
continue;
}
let options: Vec<String> = choices
.iter()
.map(|p| ui::option_line(p.key, p.description, p.maintenance.short_badge()))
.collect();
let prompt = format!("{}:", category.label());
crate::diagnostics::event("prompt", &prompt);
if category.allows_multiple() {
let selected = MultiSelect::new(&prompt, options)
.with_help_message(ui::MULTISELECT_HELP)
.with_formatter(&ui::compact_multi_answer)
.prompt()?;
for spec in &choices {
if selected.iter().any(|s| ui::option_is(s, spec.key)) {
packages.insert(spec.key.to_string());
}
}
} else {
let mut options = options;
options.insert(0, "none".to_string());
let picked = Select::new(&prompt, options)
.with_formatter(&ui::compact_select_answer)
.prompt()?;
if picked != "none"
&& let Some(spec) = choices.iter().find(|p| ui::option_is(&picked, p.key))
{
packages.insert(spec.key.to_string());
}
}
}
add_companions(&mut packages);
Ok(packages)
}
fn pick_expert(workflow: PackageWorkflow) -> Result<BTreeSet<String>> {
let offerable: Vec<&PackageSpec> = wally_packages::PACKAGES
.iter()
.filter(|p| offerable_package(p, workflow))
.collect();
let options: Vec<String> = offerable
.iter()
.map(|p| {
format!(
"{} - {} [{}] ({})",
p.key,
p.description,
p.category.label(),
p.maintenance.short_badge()
)
})
.collect();
let selected = MultiSelect::new("Pick every package this project needs", options)
.with_help_message(ui::MULTISELECT_HELP)
.with_formatter(&ui::compact_multi_answer)
.prompt()?;
Ok(offerable
.iter()
.filter(|p| selected.iter().any(|s| ui::option_is(s, p.key)))
.map(|p| p.key.to_string())
.collect())
}
pub(super) fn add_companions(packages: &mut BTreeSet<String>) {
let primaries: Vec<String> = packages.iter().cloned().collect();
let snapshot = packages.clone();
for key in primaries {
for companion in companions_for(&key, |k| snapshot.contains(k)) {
packages.insert(companion.to_string());
}
}
}
struct ScaffoldOptions<'a> {
packages: &'a BTreeSet<String>,
package_workflow: PackageWorkflow,
project_tools: &'a [String],
test_runner: Option<TestRunner>,
jest_backend: crate::graph::JestBackend,
chosen_artifacts: &'a [String],
project_template: Option<&'a serde_json::Value>,
}
fn scaffold(project_dir: &Path, name: &str, options: ScaffoldOptions<'_>) -> Result<()> {
let ScaffoldOptions {
packages,
package_workflow,
project_tools,
test_runner,
jest_backend,
chosen_artifacts,
project_template,
} = options;
let writes = |key: &str| chosen_artifacts.iter().any(|k| k == key);
git::ensure_repo_init(project_dir)?;
if writes("rokit.toml") {
toolchain::ensure_rokit_init(project_dir)?;
toolchain::add_selected_tools(project_dir, project_tools)?;
}
if writes("selene.toml") {
toolchain::ensure_selene_config(
project_dir,
packages.contains("testez"),
package_workflow,
wally_packages::allows_mixed_tables(packages),
)?;
}
if writes("stylua.toml") {
toolchain::ensure_stylua_config(project_dir)?;
}
let testez_selected = test_runner == Some(TestRunner::TestEz);
let has_server_packages =
package_workflow == PackageWorkflow::Wally && wally_packages::has_server_realm(packages);
rojo::scaffold_project_json(
project_dir,
name,
package_workflow,
testez_selected,
has_server_packages,
project_template,
)?;
if writes("tests") {
let examples = writes("test-examples");
match test_runner {
Some(TestRunner::TestEz) => {
testez::ensure_test_tree(project_dir, examples)?;
testez::ensure_tests_luaurc(project_dir)?;
}
Some(TestRunner::JestRoblox) => jest::ensure_test_tree(project_dir, examples)?,
None => {}
}
}
if writes("jest.project.json") {
jest::refresh_project(project_dir)?;
}
if writes("jest.config.json") {
jest::ensure_config(project_dir, jest_backend)?;
}
match package_workflow {
PackageWorkflow::Wally if writes("wally.toml") => {
let package_name = format!("rproj/{}", slugify(name));
let package_list: Vec<String> = packages.iter().cloned().collect();
wally::ensure_wally_init(project_dir)?;
wally::write_wally_toml(project_dir, &package_name, &package_list)?;
let sourcemap_project = if test_runner == Some(TestRunner::JestRoblox) {
jest::PROJECT_FILE
} else {
"default.project.json"
};
wally::sync_for_project(project_dir, sourcemap_project)?;
}
PackageWorkflow::GitSubmodules if writes("modules") => {
let mut cloned = BTreeSet::new();
for spec in modules::vendorable(packages) {
let sub = spec.submodule.expect("vendorable() filtered to Some");
if cloned.insert(sub.dir) {
git::add_submodule(project_dir, spec.git_repo, sub.dir)?;
}
}
modules::write_submodules_project(project_dir, packages)?;
modules::write_link_files(project_dir, packages)?;
if writes("sourcemap.json") {
rojo::generate_sourcemap(project_dir)?;
}
}
_ => {
if writes("sourcemap.json") {
rojo::generate_sourcemap(project_dir)?;
}
}
}
if writes("testez.yml") {
testez::ensure_selene_std(project_dir)?;
}
if writes("testez-companion.toml") {
testez::ensure_companion_config(project_dir)?;
}
if writes(".vscode/settings.json") {
vscode::ensure_project_settings(project_dir, package_workflow)?;
}
if writes(".luaurc") {
quality::ensure_luaurc(project_dir)?;
}
if writes(".lute/check.luau")
&& quality::ensure_check_script(project_dir, project_tools, test_runner.is_some())?
{
if writes(".github/workflows/ci.yml") {
quality::ensure_ci_workflow(
project_dir,
package_workflow,
has_server_packages,
test_runner,
jest_backend,
)?;
}
quality::lute_setup(project_dir)?;
}
if writes(".gitignore") {
gitignore::ensure_entries(project_dir)?;
}
if writes(".gitattributes") {
gitattributes::ensure_gitattributes(project_dir)?;
}
if writes("blender") {
blender::scaffold_starter_scene(project_dir)?;
}
if writes("figma") {
figma::scaffold_design_folder(project_dir)?;
}
if writes("asphalt.toml") {
asphalt::ensure_config(project_dir)?;
}
if writes("tungsten.toml") {
tungsten::ensure_config(project_dir)?;
}
Ok(())
}
fn slugify(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect()
}
fn load_setup(name: &str) -> Result<(String, ProjectGraph)> {
let (setup, warnings) = read_setup(name)?;
for warning in warnings {
ui::warn(&warning);
}
Ok((name.to_string(), setup))
}
pub(super) fn read_setup(name: &str) -> Result<(ProjectGraph, Vec<String>)> {
let mut warnings = Vec::new();
if let Some(mut setup) = Setups::load(name)? {
if !setup.testing_is_compatible() {
bail!(
"saved setup `{name}` selects Jest Roblox without Wally; edit or replace the setup before using it"
);
}
let selected: BTreeSet<String> = setup.packages.iter().cloned().collect();
let blocked = wally_packages::unvendorable_in_closure(&selected);
if setup.package_workflow == PackageWorkflow::GitSubmodules && !blocked.is_empty() {
let reasons: Vec<String> = blocked
.iter()
.map(|(key, via)| match via {
Some(dependent) => format!("{key} (required by {dependent})"),
None => (*key).to_string(),
})
.collect();
warnings.push(format!(
"setup `{name}` asks for git submodules, but {} can't be vendored that way - using Wally",
reasons.join(", ")
));
setup.package_workflow = PackageWorkflow::Wally;
}
let unknown: Vec<&str> = setup
.packages
.iter()
.filter(|k| wally_packages::find(k).is_none())
.map(String::as_str)
.collect();
if !unknown.is_empty() {
warnings.push(format!(
"setup `{name}` names packages no longer in the catalog, skipping them: {}",
unknown.join(", ")
));
setup.packages.retain(|k| wally_packages::find(k).is_some());
}
return Ok((setup, warnings));
}
let available = Setups::list();
if available.is_empty() {
bail!(
"no saved setup called `{name}` - none have been saved yet. \
Add `--save-setup <name>` to a `rproj new` run to create one."
);
}
bail!(
"no saved setup called `{name}`. Available: {}",
available.join(", ")
)
}
#[cfg(test)]
mod tests {
#[test]
fn capability_prompt_driver() {
let Ok(workflow) = std::env::var("RPROJ_CAPABILITY_PROMPT_WORKFLOW") else {
return;
};
let workflow = serde_json::from_str(&format!("\"{workflow}\"")).unwrap();
let choices = super::pick_capabilities(workflow).unwrap();
let mut graph = ProjectGraph::default();
for (key, implementation) in choices {
graph.choose(&key, implementation.as_deref());
}
println!(
"CHOICES {}",
serde_json::to_string(&graph.capabilities).unwrap()
);
}
#[test]
fn direct_capability_prompts_keep_runner_choices_and_skip_missing_requirements() {
use crate::test_common::{DOWN, ENTER, Session};
for (workflow, capability, backend, expected) in [
("wally", "test", "local", "jest-roblox"),
("wally", "test", "cloud", "jest-roblox-open-cloud"),
("git-submodules", "test", "", "testez"),
("none", "test", "", "testez"),
("wally", "ci", "", ""),
] {
let root = tempfile::tempdir().unwrap();
let mut session = Session::start_program(
&std::env::current_exe().unwrap(),
root.path(),
&[
"commands::new::tests::capability_prompt_driver",
"--exact",
"--nocapture",
],
&[("RPROJ_CAPABILITY_PROMPT_WORKFLOW", workflow)],
);
session.wait_for("What should this project do?");
let index = capabilities::CAPABILITIES
.iter()
.position(|c| c.key == capability)
.unwrap();
for _ in 0..index {
session.send(DOWN);
}
session.send(" ");
session.send(ENTER);
if !backend.is_empty() {
session.wait_for("test:");
session.send(ENTER);
session.wait_for("Jest execution:");
if backend == "cloud" {
session.send(DOWN);
}
session.send(ENTER);
}
let result = session.finish();
assert_eq!(result.code, 0, "{}", result.text);
if capability == "ci" {
assert!(
result.text.contains("ci needs gate - skipping it"),
"{}",
result.text
);
assert!(result.text.contains("CHOICES {}"), "{}", result.text);
} else {
assert!(
result
.text
.contains(&format!("CHOICES {{\"test\":\"{expected}\"}}")),
"{}",
result.text
);
}
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
}
}
#[test]
fn creating_a_project_never_reuses_an_existing_directory() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("existing");
std::fs::create_dir(&path).unwrap();
std::fs::write(path.join("user.txt"), "preserve me").unwrap();
assert!(super::create_project_dir(&path).is_err());
let mut config = GlobalConfig::default();
let before = serde_json::to_value(&config).unwrap();
let mut graph = ProjectGraph {
package_workflow: PackageWorkflow::Wally,
..Default::default()
};
graph.choose("test", Some("jest-roblox"));
let planned = graph.plan(&[], &[]);
assert!(
execute_confirmed("existing", &path, &graph, &planned, None, &mut config, None,)
.is_err()
);
assert_eq!(serde_json::to_value(&config).unwrap(), before);
assert_eq!(std::fs::read_dir(&path).unwrap().count(), 1);
assert_eq!(
std::fs::read_to_string(path.join("user.txt")).unwrap(),
"preserve me"
);
}
#[test]
fn creating_a_project_creates_missing_parents() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("projects/new");
super::create_project_dir(&path).unwrap();
assert!(path.is_dir());
}
use super::*;
#[test]
fn the_package_picker_never_offers_what_the_strategy_cannot_install() {
for spec in wally_packages::PACKAGES {
if offerable_package(spec, PackageWorkflow::GitSubmodules) {
assert!(
spec.submodule.is_some(),
"{} has no vendorable source but is offered under submodules",
spec.key
);
}
assert_eq!(
offerable_package(spec, PackageWorkflow::Wally),
!capability_owned(spec.key),
"{}",
spec.key
);
}
}
#[test]
fn the_test_runner_is_not_offered_as_a_package() {
assert!(
capability_owned("testez"),
"the test capability must own it"
);
let testez = wally_packages::find("testez").expect("in the catalog");
for workflow in [PackageWorkflow::Wally, PackageWorkflow::GitSubmodules] {
assert!(!offerable_package(testez, workflow));
}
}
#[test]
fn machine_is_only_configured_once() {
let fresh = GlobalConfig::default();
assert!(
!fresh.machine_configured(),
"a fresh config needs provisioning"
);
let provisioned = GlobalConfig {
last_checked: Some("123".into()),
..Default::default()
};
assert!(
provisioned.machine_configured(),
"already provisioned, don't re-ask"
);
}
#[test]
fn machine_summary_describes_what_is_set_up() {
let config = GlobalConfig {
selected_system_apps: vec!["git".into(), "vscode".into()],
selected_rokit_tools: vec!["rojo".into()],
..Default::default()
};
let summary = config.machine_summary();
assert!(summary.contains("2 apps"), "{summary}");
assert!(summary.contains("1 tools"), "{summary}");
assert!(
!summary.contains("plugins"),
"empty groups shouldn't be listed: {summary}"
);
assert_eq!(
GlobalConfig::default().machine_summary(),
"nothing selected"
);
}
fn graph_of(workflow: PackageWorkflow, packages: &[&str], caps: &[&str]) -> ProjectGraph {
let mut graph = ProjectGraph {
package_workflow: workflow,
packages: packages.iter().map(|p| p.to_string()).collect(),
..Default::default()
};
for key in caps {
graph.choose(key, None);
}
graph
}
#[test]
fn choosing_nothing_yields_only_the_basics() {
let graph = graph_of(PackageWorkflow::None, &[], &[]);
let keys: Vec<&str> = graph.plan(&[], &[]).iter().map(|p| p.key).collect();
assert_eq!(
keys,
["src", "default.project.json", "rproj.toml", ".gitignore"]
);
}
#[test]
fn one_capability_derives_both_the_tool_and_the_file() {
let graph = graph_of(PackageWorkflow::None, &[], &["lint"]);
assert_eq!(graph.tools(), ["selene"]);
let keys: Vec<&str> = graph.plan(&[], &[]).iter().map(|p| p.key).collect();
assert!(keys.contains(&"selene.toml"), "{keys:?}");
}
#[test]
fn the_summary_names_every_file_and_why_it_is_there() {
let graph = graph_of(PackageWorkflow::Wally, &["reflex"], &["lint", "format"]);
let planned = graph.plan(&[], &[]);
let text = summary_lines("MyGame", &graph, &planned).join(
"
",
);
assert!(text.contains(" MyGame"), "{text}");
assert!(text.contains("Dependencies Wally"), "{text}");
assert!(text.contains("Packages reflex"), "{text}");
assert!(text.contains("lint (Selene)"), "{text}");
assert!(text.contains("format (StyLua)"), "{text}");
assert!(text.contains("rproj configure selene"), "{text}");
assert!(text.contains("rproj configure stylua"), "{text}");
assert!(!text.contains("rproj configure luau-lsp"), "{text}");
assert!(!text.contains("rproj configure stylua-vscode"), "{text}");
assert!(text.contains("selene.toml"), "{text}");
assert!(text.contains("you chose lint"), "{text}");
assert!(text.contains("wally.toml"), "{text}");
assert!(text.contains("this project uses Wally"), "{text}");
}
#[test]
fn the_summary_says_so_when_nothing_extra_was_chosen() {
let graph = graph_of(PackageWorkflow::None, &[], &[]);
let planned = graph.plan(&[], &[]);
let text = summary_lines("bare", &graph, &planned).join(
"
",
);
assert!(text.contains("Dependencies none"), "{text}");
assert!(text.contains("Packages none"), "{text}");
assert!(text.contains("Does nothing extra"), "{text}");
assert!(!text.contains("Configure after creation"), "{text}");
}
#[test]
fn editor_summary_offers_only_relevant_editor_configuration() {
let graph = graph_of(PackageWorkflow::None, &[], &["editor"]);
let planned = graph.plan(&["vscode".to_string()], &[]);
let text = summary_lines("editor", &graph, &planned).join("\n");
assert!(text.contains("rproj configure luau-lsp"), "{text}");
assert!(!text.contains("rproj configure stylua-vscode"), "{text}");
let formatted = graph_of(PackageWorkflow::None, &[], &["format", "editor"]);
let planned = formatted.plan(&["vscode".to_string()], &[]);
let text = summary_lines("formatted-editor", &formatted, &planned).join("\n");
assert!(text.contains("rproj configure stylua-vscode"), "{text}");
}
#[test]
fn the_revision_menu_describes_the_current_answers() {
let graph = graph_of(PackageWorkflow::GitSubmodules, &["charm"], &["lint"]);
assert_eq!(describe_node(&graph, Node::Strategy), "git submodules");
assert_eq!(describe_node(&graph, Node::Packages), "charm");
assert_eq!(describe_node(&graph, Node::Capabilities), "lint");
let empty = ProjectGraph::default();
assert_eq!(describe_node(&empty, Node::Packages), "none");
assert_eq!(describe_node(&empty, Node::Capabilities), "nothing extra");
}
}