use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{bail, Context, Result};
use inquire::{MultiSelect, Select};
use crate::catalog::wally_packages::{self, companions_for, Category, PackageSpec};
use crate::commands::provision;
use crate::catalog::artifacts::{self, Selections, Workflow};
use crate::config::{GlobalConfig, PackageWorkflow, ProjectConfig, SavedSetup};
use crate::steps::{
blender, git, gitattributes, gitignore, modules, quality, rojo, testez, toolchain, 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 = config.projects_root()?.join(name);
if project_dir.exists() {
bail!("{} already exists", project_dir.display());
}
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)?;
config.save()?;
} else {
ui::ok(&format!("machine ready: {}", config.machine_summary()));
ui::detail("rproj setup to re-check, or rproj new --reconfigure to change");
}
std::fs::create_dir_all(&project_dir)
.with_context(|| format!("failed to create {}", project_dir.display()))?;
ui::section(&format!("Scaffolding {name}"));
ui::detail(&project_dir.display().to_string());
let (mode, packages, package_workflow) = match saved {
Some((setup_name, setup)) => {
let packages: BTreeSet<String> = setup.packages.into_iter().collect();
ui::ok(&format!(
"using saved setup `{setup_name}`: {}",
if packages.is_empty() { "no packages".to_string() } else { packages.iter().cloned().collect::<Vec<_>>().join(", ") }
));
(format!("like:{setup_name}"), packages, setup.package_workflow)
}
None => {
let (mode, packages) = pick_composition()?;
let workflow = pick_package_workflow(&packages)?;
(mode.to_string(), packages, workflow)
}
};
let packages = match package_workflow {
PackageWorkflow::Wally => packages,
PackageWorkflow::GitSubmodules => {
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
}
};
let selected_packages: Vec<String> = packages.iter().cloned().collect();
let selected_tools = tools_for_workflow(&config, package_workflow);
let chosen_artifacts = pick_artifacts(&Selections {
packages: &selected_packages,
tools: &selected_tools,
apps: &config.selected_system_apps,
workflow: match package_workflow {
PackageWorkflow::Wally => Workflow::Wally,
PackageWorkflow::GitSubmodules => Workflow::GitSubmodules,
},
})?;
scaffold(
&project_dir,
name,
&config,
&packages,
package_workflow,
&chosen_artifacts,
)?;
ProjectConfig {
mode,
package_workflow,
packages: packages.iter().cloned().collect(),
tools_at_creation: config.selected_rokit_tools.clone(),
}
.save_to(&project_dir)?;
if let Some(setup_name) = save_setup {
let setup = SavedSetup {
packages: packages.iter().cloned().collect(),
package_workflow,
};
setup.save(setup_name)?;
ui::ok(&format!("saved setup `{setup_name}` - reuse with `rproj new <name> --like {setup_name}`"));
}
let (party, folder, eye, gate, book) = match ui::emoji_enabled() {
true => ("🎉 ", "📁", "👀", "🧪", "📖"),
false => ("", " ", " ", " ", " "),
};
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(())
}
fn pick_composition() -> Result<(&'static str, BTreeSet<String>)> {
let mode = Select::new(
"How do you want to set up this project's packages?",
vec![
"Guided walkthrough - answer one question per category, with explanations (recommended for beginners)",
"Expert checklist - pick anything, no hand-holding",
],
)
.prompt()?;
if mode.starts_with("Guided") {
Ok(("guided", pick_guided()?))
} else {
Ok(("expert", pick_expert()?))
}
}
fn pick_package_workflow(packages: &BTreeSet<String>) -> Result<PackageWorkflow> {
if packages.is_empty() {
return Ok(PackageWorkflow::Wally);
}
let blocked = wally_packages::unvendorable_in_closure(packages);
if !blocked.is_empty() {
for (key, pulled_in_by) in &blocked {
match pulled_in_by {
Some(dependent) => println!(
"note: {dependent} requires {key}, which upstream only ships through an \
npm/pnpm install step - git submodules can't reproduce that."
),
None => println!(
"note: {key} only ships a working module through an npm/pnpm install step \
upstream, which git submodules can't reproduce."
),
}
}
println!(" Using Wally for this project instead.");
return Ok(PackageWorkflow::Wally);
}
let choice = Select::new(
"How do you want to pull in this project's packages?",
vec![
"Wally - the standard Roblox/Luau package manager (recommended)",
"Git submodules - clone each package's own repo instead of using Wally",
],
)
.prompt()?;
Ok(if choice.starts_with("Wally") {
PackageWorkflow::Wally
} else {
PackageWorkflow::GitSubmodules
})
}
fn pick_guided() -> 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)
.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());
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.push("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() -> Result<BTreeSet<String>> {
let options: Vec<String> = wally_packages::PACKAGES
.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(wally_packages::PACKAGES
.iter()
.filter(|p| selected.iter().any(|s| ui::option_is(s, p.key)))
.map(|p| p.key.to_string())
.collect())
}
fn pick_artifacts(selections: &Selections) -> Result<Vec<String>> {
let offerable: Vec<&'static artifacts::Artifact> = artifacts::offerable(selections)
.into_iter()
.filter(|a| !a.mandatory)
.collect();
if offerable.is_empty() {
return Ok(Vec::new());
}
let options: Vec<String> = offerable
.iter()
.map(|a| ui::option_line(a.key, a.description, a.category.label()))
.collect();
let defaults: Vec<usize> = offerable
.iter()
.enumerate()
.filter(|(_, a)| a.default_selected)
.map(|(i, _)| i)
.collect();
let picked = MultiSelect::new("Files to generate", options)
.with_default(&defaults)
.with_help_message(ui::MULTISELECT_HELP)
.with_formatter(&ui::compact_multi_answer)
.prompt()?;
Ok(offerable
.iter()
.filter(|a| picked.iter().any(|p| ui::option_is(p, a.key)))
.map(|a| a.key.to_string())
.collect())
}
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());
}
}
}
fn scaffold(
project_dir: &Path,
name: &str,
config: &GlobalConfig,
packages: &BTreeSet<String>,
package_workflow: PackageWorkflow,
chosen_artifacts: &[String],
) -> Result<()> {
let selected_packages: Vec<String> = packages.iter().cloned().collect();
let selected_tools = tools_for_workflow(config, package_workflow);
let selections = Selections {
packages: &selected_packages,
tools: &selected_tools,
apps: &config.selected_system_apps,
workflow: match package_workflow {
PackageWorkflow::Wally => Workflow::Wally,
PackageWorkflow::GitSubmodules => Workflow::GitSubmodules,
},
};
let written = artifacts::resolve(&selections, chosen_artifacts);
let writes = |key: &str| written.iter().any(|a| a.key == key);
git::ensure_repo_init(project_dir)?;
toolchain::ensure_rokit_init(project_dir)?;
toolchain::add_selected_tools(project_dir, &tools_for_workflow(config, package_workflow))?;
toolchain::ensure_selene_config(
project_dir,
packages.contains("testez"),
package_workflow,
wally_packages::allows_mixed_tables(packages),
)?;
toolchain::ensure_stylua_config(project_dir)?;
let testez_selected = packages.contains("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,
)?;
if testez_selected {
testez::ensure_test_folders(project_dir)?;
}
match package_workflow {
PackageWorkflow::Wally => {
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)?;
wally::sync(project_dir)?;
}
PackageWorkflow::GitSubmodules => {
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)?;
rojo::generate_sourcemap(project_dir)?;
}
}
if testez_selected {
testez::ensure_selene_std(project_dir)?;
testez::ensure_tests_luaurc(project_dir)?;
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,
&tools_for_workflow(config, package_workflow),
testez_selected,
)?
{
if writes(".github/workflows/ci.yml") {
quality::ensure_ci_workflow(project_dir, package_workflow, has_server_packages)?;
}
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)?;
}
Ok(())
}
fn slugify(name: &str) -> String {
name.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect()
}
fn tools_for_workflow(config: &GlobalConfig, workflow: PackageWorkflow) -> Vec<String> {
const WALLY_ONLY: &[&str] = &["wally", "wally-package-types"];
config
.selected_rokit_tools
.iter()
.filter(|key| workflow == PackageWorkflow::Wally || !WALLY_ONLY.contains(&key.as_str()))
.cloned()
.collect()
}
fn load_setup(name: &str) -> Result<(String, SavedSetup)> {
if let Some(mut setup) = SavedSetup::load(name)? {
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();
ui::warn(&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() {
ui::warn(&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((name.to_string(), setup));
}
let available = SavedSetup::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 {
use super::*;
fn config_with(tools: &[&str]) -> GlobalConfig {
GlobalConfig {
selected_rokit_tools: tools.iter().map(|t| t.to_string()).collect(),
..Default::default()
}
}
#[test]
fn submodule_projects_do_not_pin_wally_tools() {
let config = config_with(&["rojo", "wally", "wally-package-types", "selene"]);
let tools = tools_for_workflow(&config, PackageWorkflow::GitSubmodules);
assert_eq!(tools, vec!["rojo", "selene"]);
}
#[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");
}
#[test]
fn wally_projects_keep_every_selected_tool() {
let config = config_with(&["rojo", "wally", "wally-package-types", "selene"]);
let tools = tools_for_workflow(&config, PackageWorkflow::Wally);
assert_eq!(tools, vec!["rojo", "wally", "wally-package-types", "selene"]);
}
}