use std::collections::BTreeSet;
use std::path::Path;
use anyhow::{bail, Context, Result};
use inquire::{MultiSelect, Select};
use crate::catalog::artifacts;
use crate::catalog::capabilities;
use crate::catalog::wally_packages::{self, companions_for, Category, PackageSpec};
use crate::commands::provision;
use crate::config::{GlobalConfig, PackageWorkflow, ProjectConfig, SavedSetup};
use crate::steps::{
blender, figma, git, gitattributes, gitignore, modules, quality, rojo, tarmac, 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 workflow = pick_strategy()?;
let (mode, packages) = match workflow {
PackageWorkflow::None => ("none", BTreeSet::new()),
_ => pick_composition(workflow)?,
};
let (workflow, packages) = reconcile_strategy(workflow, packages)?;
(mode.to_string(), packages, workflow)
}
};
let mut packages = match package_workflow {
PackageWorkflow::Wally | PackageWorkflow::None => 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 chosen_capabilities = pick_capabilities()?;
let derived = capabilities::derive(&chosen_capabilities);
let capability_keys: Vec<String> =
chosen_capabilities.iter().map(|(k, _)| k.clone()).collect();
for key in &derived.packages {
packages.insert(key.clone());
}
let mut project_tools = derived.tools.clone();
for tool in strategy_tools(package_workflow, &packages) {
if !project_tools.iter().any(|t| t == tool) {
project_tools.push(tool.to_string());
}
}
let environment = artifacts::Environment {
apps: &config.selected_system_apps,
extensions: &config.selected_vscode_extensions,
strategy: strategy_of(package_workflow),
};
let planned = artifacts::plan(
&environment,
&capability_keys,
&derived.artifacts,
&project_tools,
&[],
);
let full_plan = planned.clone();
let mut planned = planned;
let mut dropped: Vec<String> = Vec::new();
loop {
match confirm_plan(name, &packages, package_workflow, &chosen_capabilities, &planned)? {
Outcome::Create => break,
Outcome::Customize => {
dropped = customize_plan(&full_plan, &dropped)?;
planned = artifacts::plan(
&environment,
&capability_keys,
&derived.artifacts,
&project_tools,
&dropped,
);
}
Outcome::Cancel => {
std::fs::remove_dir_all(&project_dir).ok();
println!("\nNothing created.");
return Ok(());
}
}
}
let chosen_artifacts: Vec<String> = planned.iter().map(|p| p.key.to_string()).collect();
scaffold(
&project_dir,
name,
&packages,
package_workflow,
&project_tools,
&chosen_artifacts,
)?;
let writes = |key: &str| chosen_artifacts.iter().any(|k| k == key);
if writes("rproj.toml") {
ProjectConfig {
mode,
package_workflow,
packages: packages.iter().cloned().collect(),
tools_at_creation: project_tools.clone(),
}
.save_to(&project_dir)?;
} else {
ui::skip("rproj.toml not written, so `rproj upgrade` won't know this project");
}
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_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 strategy_of(workflow: PackageWorkflow) -> artifacts::Strategy {
match workflow {
PackageWorkflow::Wally => artifacts::Strategy::Wally,
PackageWorkflow::GitSubmodules => artifacts::Strategy::GitSubmodules,
PackageWorkflow::None => artifacts::Strategy::None,
}
}
fn pick_capabilities() -> Result<Vec<(String, Option<String>)>> {
let offerable: Vec<&'static capabilities::Capability> =
capabilities::CAPABILITIES.iter().collect();
let options: Vec<String> = offerable
.iter()
.map(|c| ui::option_line(c.key, c.outcome, c.default_implementation().display))
.collect();
let defaults: Vec<usize> = offerable
.iter()
.enumerate()
.filter(|(_, c)| c.default_selected)
.map(|(i, _)| i)
.collect();
let picked = MultiSelect::new("What should this project do?", options)
.with_default(&defaults)
.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;
}
let keys: Vec<String> = chosen.iter().map(|(k, _)| k.clone()).collect();
if !capability.requires.iter().all(|r| keys.iter().any(|k| k == r)) {
ui::skip(&format!(
"{} needs {} - skipping it",
capability.key,
capability.requires.join(", ")
));
continue;
}
let implementation = if capability.needs_an_implementation_prompt() {
let options: Vec<String> = capability
.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 {
None
};
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)))
}
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()
}
fn offerable_package(spec: &PackageSpec, workflow: PackageWorkflow) -> bool {
!capability_owned(spec.key)
&& (workflow != PackageWorkflow::GitSubmodules || spec.submodule.is_some())
}
fn confirm_plan(
name: &str,
packages: &BTreeSet<String>,
workflow: PackageWorkflow,
chosen: &[(String, Option<String>)],
planned: &[artifacts::Planned],
) -> Result<Outcome> {
println!();
for line in summary_lines(name, packages, workflow, chosen, planned) {
println!("{line}");
}
println!();
let choice = Select::new(
"Create it?",
owned(&[
"create - go ahead and scaffold this",
"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),
"customize" => Ok(Outcome::Customize),
_ => Ok(Outcome::Cancel),
}
}
#[derive(PartialEq, Eq)]
enum Outcome {
Create,
Customize,
Cancel,
}
fn customize_plan(
full_plan: &[artifacts::Planned],
dropped: &[String],
) -> Result<Vec<String>> {
let droppable: Vec<&artifacts::Planned> = full_plan
.iter()
.filter(|p| artifacts::find(p.key).is_some_and(|a| !a.mandatory))
.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())
}
fn summary_lines(
name: &str,
packages: &BTreeSet<String>,
workflow: PackageWorkflow,
chosen: &[(String, Option<String>)],
planned: &[artifacts::Planned],
) -> Vec<String> {
let mut lines = vec![format!(" {name}"), String::new()];
let strategy = match 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(", ") }
));
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());
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())
}
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,
packages: &BTreeSet<String>,
package_workflow: PackageWorkflow,
project_tools: &[String],
chosen_artifacts: &[String],
) -> Result<()> {
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 = 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 writes("tests") {
testez::ensure_test_folders(project_dir)?;
testez::ensure_tests_luaurc(project_dir)?;
}
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)?;
wally::sync(project_dir)?;
}
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, 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)?;
}
if writes("figma") {
figma::scaffold_design_folder(project_dir)?;
}
if writes("tarmac.toml") {
tarmac::ensure_config(project_dir, &slugify(name))?;
}
Ok(())
}
fn slugify(name: &str) -> String {
name.chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
.collect()
}
fn strategy_tools(workflow: PackageWorkflow, packages: &BTreeSet<String>) -> &'static [&'static str] {
match workflow {
PackageWorkflow::Wally if !packages.is_empty() => &["wally", "wally-package-types"],
_ => &[],
}
}
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::*;
#[test]
fn a_wally_project_with_packages_pins_wally_and_the_retyper() {
let packages: BTreeSet<String> = ["reflex".to_string()].into_iter().collect();
assert_eq!(
strategy_tools(PackageWorkflow::Wally, &packages),
["wally", "wally-package-types"]
);
}
#[test]
fn a_wally_project_with_no_packages_pins_nothing() {
assert!(strategy_tools(PackageWorkflow::Wally, &BTreeSet::new()).is_empty());
}
#[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 submodule_projects_do_not_pin_wally_tools() {
let packages: BTreeSet<String> = ["charm".to_string()].into_iter().collect();
assert!(strategy_tools(PackageWorkflow::GitSubmodules, &packages).is_empty());
assert!(strategy_tools(PackageWorkflow::None, &packages).is_empty());
}
#[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 chosen(keys: &[&str]) -> Vec<(String, Option<String>)> {
keys.iter().map(|k| (k.to_string(), None)).collect()
}
fn plan_for(
capability_keys: &[&str],
workflow: PackageWorkflow,
) -> Vec<artifacts::Planned> {
let selected = chosen(capability_keys);
let derived = capabilities::derive(&selected);
let (apps, extensions) = (Vec::new(), Vec::new());
let environment = artifacts::Environment {
apps: &apps,
extensions: &extensions,
strategy: strategy_of(workflow),
};
let keys: Vec<String> = capability_keys.iter().map(|k| k.to_string()).collect();
artifacts::plan(&environment, &keys, &derived.artifacts, &derived.tools, &[])
}
#[test]
fn choosing_nothing_yields_only_the_basics() {
let planned = plan_for(&[], PackageWorkflow::None);
let keys: Vec<&str> = planned.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 derived = capabilities::derive(&chosen(&["lint"]));
assert_eq!(derived.tools, ["selene"]);
let keys: Vec<&str> = plan_for(&["lint"], PackageWorkflow::None)
.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 packages: BTreeSet<String> = ["reflex".to_string()].into_iter().collect();
let selected = chosen(&["lint", "format"]);
let planned = plan_for(&["lint", "format"], PackageWorkflow::Wally);
let lines = summary_lines("MyGame", &packages, PackageWorkflow::Wally, &selected, &planned);
let text = lines.join("\n");
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("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 planned = plan_for(&[], PackageWorkflow::None);
let lines = summary_lines("bare", &BTreeSet::new(), PackageWorkflow::None, &[], &planned);
let text = lines.join("\n");
assert!(text.contains("Dependencies none"), "{text}");
assert!(text.contains("Packages none"), "{text}");
assert!(text.contains("Does nothing extra"), "{text}");
}
#[test]
fn a_wally_project_with_packages_pins_the_package_manager() {
let packages: BTreeSet<String> = ["reflex".to_string()].into_iter().collect();
assert_eq!(
strategy_tools(PackageWorkflow::Wally, &packages),
["wally", "wally-package-types"]
);
}
#[test]
fn wally_with_no_packages_pins_nothing() {
assert!(strategy_tools(PackageWorkflow::Wally, &BTreeSet::new()).is_empty());
}
}