use crate::config::PackageWorkflow;
pub struct Implementation {
pub key: &'static str,
pub display: &'static str,
pub tools: &'static [&'static str],
pub packages: &'static [&'static str],
pub artifacts: &'static [&'static str],
pub workflows: &'static [PackageWorkflow],
}
pub struct Capability {
pub key: &'static str,
pub outcome: &'static str,
pub implementations: &'static [Implementation],
pub requires: &'static [&'static str],
pub default_selected: bool,
}
fn chosen_has(chosen: &[String], key: &str) -> bool {
chosen.iter().any(|candidate| candidate == key)
}
impl Capability {
pub fn default_implementation(&self) -> &'static Implementation {
&self.implementations[0]
}
pub fn implementation(&self, key: &str) -> Option<&'static Implementation> {
self.implementations.iter().find(|i| i.key == key)
}
pub fn implementations_for(&self, workflow: PackageWorkflow) -> Vec<&'static Implementation> {
self.implementations
.iter()
.filter(|implementation| implementation.workflows.contains(&workflow))
.collect()
}
pub fn needs_an_implementation_prompt(&self, workflow: PackageWorkflow) -> bool {
self.implementations_for(workflow).len() > 1
}
}
pub const CAPABILITIES: &[Capability] = &[
Capability {
key: "lint",
outcome: "Catch bugs and risky patterns before they ship",
implementations: &[Implementation {
key: "selene",
display: "Selene",
tools: &["selene"],
packages: &[],
artifacts: &["selene.toml"],
workflows: PackageWorkflow::ALL,
}],
requires: &[],
default_selected: true,
},
Capability {
key: "format",
outcome: "One consistent code style, applied automatically",
implementations: &[Implementation {
key: "stylua",
display: "StyLua",
tools: &["stylua"],
packages: &[],
artifacts: &["stylua.toml", ".gitattributes"],
workflows: PackageWorkflow::ALL,
}],
requires: &[],
default_selected: true,
},
Capability {
key: "typecheck",
outcome: "Strict Luau, so type errors are errors and not surprises",
implementations: &[Implementation {
key: "luau-lsp",
display: "luau-lsp",
tools: &["luau-lsp-cli"],
packages: &[],
artifacts: &[".luaurc"],
workflows: PackageWorkflow::ALL,
}],
requires: &[],
default_selected: true,
},
Capability {
key: "test",
outcome: "Write and run tests against your game's own code",
implementations: &[
Implementation {
key: "testez",
display: "TestEZ",
tools: &[],
packages: &["testez"],
artifacts: &[
"tests",
"test-examples",
"testez.yml",
"testez-companion.toml",
],
workflows: PackageWorkflow::ALL,
},
Implementation {
key: "jest-roblox",
display: "Jest Roblox",
tools: &["jest-roblox"],
packages: &["jest", "jest-globals"],
artifacts: &[
"tests",
"test-examples",
"jest.project.json",
"jest.config.json",
],
workflows: &[PackageWorkflow::Wally],
},
],
requires: &[],
default_selected: false,
},
Capability {
key: "gate",
outcome: "One command that runs every check above, in one pass",
implementations: &[Implementation {
key: "lute",
display: "Lute",
tools: &["lute"],
packages: &[],
artifacts: &[".lute/check.luau"],
workflows: PackageWorkflow::ALL,
}],
requires: &[],
default_selected: true,
},
Capability {
key: "ci",
outcome: "Run that same gate on GitHub for every push (default off)",
implementations: &[Implementation {
key: "github-actions",
display: "GitHub Actions",
tools: &[],
packages: &[],
artifacts: &[".github/workflows/ci.yml"],
workflows: PackageWorkflow::ALL,
}],
requires: &["gate"],
default_selected: false,
},
Capability {
key: "editor",
outcome: "VS Code resolves requires and sees what you build in Studio",
implementations: &[Implementation {
key: "vscode",
display: "VS Code + luau-lsp",
tools: &["rojo"],
packages: &[],
artifacts: &[".vscode/settings.json", "sourcemap.json"],
workflows: PackageWorkflow::ALL,
}],
requires: &[],
default_selected: true,
},
Capability {
key: "asset-pipeline",
outcome: "Upload Roblox assets and reference them by name instead of by id",
implementations: &[
Implementation {
key: "asphalt",
display: "Asphalt",
tools: &["asphalt"],
packages: &[],
artifacts: &["figma", "asphalt.toml"],
workflows: PackageWorkflow::ALL,
},
Implementation {
key: "tungsten",
display: "Tungsten",
tools: &["tungsten"],
packages: &[],
artifacts: &["figma", "tungsten.toml"],
workflows: PackageWorkflow::ALL,
},
],
requires: &[],
default_selected: false,
},
Capability {
key: "assets-3d",
outcome: "A Blender scene at Roblox's unit scale (1 stud = 0.28 m)",
implementations: &[Implementation {
key: "blender",
display: "Blender",
tools: &[],
packages: &[],
artifacts: &["blender"],
workflows: PackageWorkflow::ALL,
}],
requires: &[],
default_selected: false,
},
];
pub fn find(key: &str) -> Option<&'static Capability> {
CAPABILITIES.iter().find(|c| c.key == key)
}
pub fn offerable(chosen: &[String]) -> Vec<&'static Capability> {
CAPABILITIES
.iter()
.filter(|c| c.requires.iter().all(|r| chosen_has(chosen, r)))
.collect()
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Derived {
pub tools: Vec<String>,
pub packages: Vec<String>,
pub artifacts: Vec<String>,
}
pub fn derive(selected: &[(String, Option<String>)]) -> Derived {
let mut out = Derived::default();
let keys: Vec<String> = selected.iter().map(|(k, _)| k.to_string()).collect();
let live = offerable(&keys);
for (key, implementation) in selected {
let Some(capability) = live.iter().find(|c| c.key == key) else {
continue;
};
let implementation = match implementation {
Some(i) => match capability.implementation(i) {
Some(found) => found,
None => capability.default_implementation(),
},
None => capability.default_implementation(),
};
push_new(&mut out.tools, implementation.tools);
push_new(&mut out.packages, implementation.packages);
push_new(&mut out.artifacts, implementation.artifacts);
}
out
}
fn push_new(into: &mut Vec<String>, items: &[&str]) {
for item in items {
if !into.iter().any(|existing| existing == item) {
into.push((*item).to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chosen(keys: &[&str]) -> Vec<(String, Option<String>)> {
keys.iter().map(|k| (k.to_string(), None)).collect()
}
#[test]
fn every_key_is_unique() {
let mut keys: Vec<&str> = CAPABILITIES.iter().map(|c| c.key).collect();
let before = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(before, keys.len(), "duplicate capability key");
}
#[test]
fn every_capability_has_at_least_one_implementation() {
for capability in CAPABILITIES {
assert!(
!capability.implementations.is_empty(),
"{} has no implementation, so choosing it would do nothing",
capability.key
);
}
}
#[test]
fn every_requirement_resolves_and_points_backwards() {
for (i, capability) in CAPABILITIES.iter().enumerate() {
for required in capability.requires {
let at = CAPABILITIES.iter().position(|c| c.key == *required);
let at =
at.unwrap_or_else(|| panic!("{} requires unknown {required}", capability.key));
assert!(
at < i,
"{} requires {required}, which is listed after it",
capability.key
);
}
}
}
#[test]
fn exactly_two_capabilities_offer_a_choice_of_implementation() {
let with_a_choice: Vec<&str> = CAPABILITIES
.iter()
.filter(|c| c.implementations.len() > 1)
.map(|c| c.key)
.collect();
assert_eq!(with_a_choice, ["test", "asset-pipeline"]);
}
#[test]
fn jest_is_only_available_with_wally() {
let testing = find("test").unwrap();
assert_eq!(testing.implementations_for(PackageWorkflow::Wally).len(), 2);
for workflow in [PackageWorkflow::GitSubmodules, PackageWorkflow::None] {
let implementations = testing.implementations_for(workflow);
assert_eq!(implementations.len(), 1);
assert_eq!(implementations[0].key, "testez");
}
}
#[test]
fn jest_derives_its_runner_packages_and_managed_files() {
let derived = derive(&[("test".into(), Some("jest-roblox".into()))]);
assert_eq!(derived.tools, ["jest-roblox"]);
assert_eq!(derived.packages, ["jest", "jest-globals"]);
assert!(derived.artifacts.contains(&"jest.project.json".into()));
assert!(!derived.artifacts.contains(&"testez.yml".into()));
}
#[test]
fn every_implementation_names_itself() {
for capability in CAPABILITIES {
for implementation in capability.implementations {
assert!(!implementation.display.is_empty(), "{}", capability.key);
assert!(!implementation.key.is_empty(), "{}", capability.key);
}
}
}
#[test]
fn every_implementation_derives_something() {
for capability in CAPABILITIES {
for implementation in capability.implementations {
assert!(
!implementation.artifacts.is_empty()
|| !implementation.tools.is_empty()
|| !implementation.packages.is_empty(),
"{}/{} derives nothing",
capability.key,
implementation.key
);
}
}
}
#[test]
fn choosing_lint_derives_selene_and_its_config() {
let derived = derive(&chosen(&["lint"]));
assert_eq!(derived.tools, ["selene"]);
assert_eq!(derived.artifacts, ["selene.toml"]);
assert!(derived.packages.is_empty());
}
#[test]
fn choosing_test_derives_the_package_not_just_the_files() {
let derived = derive(&chosen(&["test"]));
assert_eq!(derived.packages, ["testez"]);
assert!(derived.artifacts.contains(&"tests".to_string()));
assert!(derived.artifacts.contains(&"testez.yml".to_string()));
}
#[test]
fn choosing_an_asset_pipeline_implementation_derives_that_tool_and_config() {
let derived = derive(&[("asset-pipeline".to_string(), Some("asphalt".to_string()))]);
assert_eq!(derived.tools, ["asphalt"]);
assert_eq!(derived.artifacts, ["figma", "asphalt.toml"]);
}
#[test]
fn ci_without_the_gate_derives_nothing() {
let with = derive(&chosen(&["gate", "ci"]));
assert!(
with.artifacts
.contains(&".github/workflows/ci.yml".to_string())
);
let without = derive(&chosen(&["ci"]));
assert!(
!without
.artifacts
.contains(&".github/workflows/ci.yml".to_string()),
"{without:?}"
);
assert!(!offerable(&["ci".to_string()]).iter().any(|c| c.key == "ci"));
}
#[test]
fn choosing_nothing_derives_nothing() {
assert_eq!(derive(&[]), Derived::default());
}
#[test]
fn a_tool_wanted_by_two_capabilities_is_pinned_once() {
let mut derived = Derived::default();
push_new(&mut derived.tools, &["rojo", "selene"]);
push_new(&mut derived.tools, &["rojo"]);
assert_eq!(derived.tools, ["rojo", "selene"]);
}
#[test]
fn an_unknown_implementation_falls_back_to_the_default() {
let selected = vec![("lint".to_string(), Some("clippy".to_string()))];
assert_eq!(derive(&selected).tools, ["selene"]);
}
#[test]
fn the_deliberately_off_capabilities_are_off() {
for key in ["test", "ci", "asset-pipeline", "assets-3d"] {
assert!(
!find(key).expect(key).default_selected,
"{key} must not be pre-checked"
);
}
}
}