#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Requirement {
Package(&'static str),
Tool(&'static str),
App(&'static str),
Extension(&'static str),
Artifact(&'static str),
Workflow(Workflow),
AnyPackage,
AnyTool,
}
impl Requirement {
pub fn describe(&self) -> String {
match self {
Requirement::Package(key) => format!("the {key} package"),
Requirement::Tool(key) => format!("the {key} tool"),
Requirement::App(key) => format!("the {key} app"),
Requirement::Extension(key) => format!("the {key} VS Code extension"),
Requirement::Artifact(key) => format!("{key} to also be generated"),
Requirement::Workflow(Workflow::Wally) => "the Wally workflow".to_string(),
Requirement::Workflow(Workflow::GitSubmodules) => {
"the git-submodule workflow".to_string()
}
Requirement::AnyPackage => "any package at all".to_string(),
Requirement::AnyTool => "any tool to pin".to_string(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Entailment {
pub when: Requirement,
pub because: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Workflow {
Wally,
GitSubmodules,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtifactCategory {
Core,
Dependencies,
Quality,
Testing,
Editor,
Automation,
Assets,
}
impl ArtifactCategory {
pub fn label(&self) -> &'static str {
match self {
ArtifactCategory::Core => "Project structure",
ArtifactCategory::Dependencies => "Dependencies",
ArtifactCategory::Quality => "Linting & formatting",
ArtifactCategory::Testing => "Testing",
ArtifactCategory::Editor => "Editor integration",
ArtifactCategory::Automation => "Automation",
ArtifactCategory::Assets => "Assets",
}
}
}
pub struct Artifact {
pub key: &'static str,
pub description: &'static str,
pub category: ArtifactCategory,
pub requires: &'static [Requirement],
pub entailed_by: &'static [Entailment],
pub default_selected: bool,
pub mandatory: bool,
}
pub const ARTIFACTS: &[Artifact] = &[
Artifact {
key: "src",
description: "The shared/server/client source tree",
category: ArtifactCategory::Core,
requires: &[],
entailed_by: &[],
default_selected: true,
mandatory: true,
},
Artifact {
key: "default.project.json",
description: "Rojo project definition - the tree Studio syncs against",
category: ArtifactCategory::Core,
requires: &[],
entailed_by: &[],
default_selected: true,
mandatory: true,
},
Artifact {
key: "rokit.toml",
description: "Pins the CLI tool versions this project uses, so a teammate gets the same ones",
category: ArtifactCategory::Core,
requires: &[],
entailed_by: &[Entailment {
when: Requirement::AnyTool,
because: "it is where this project's tool versions are pinned",
}],
default_selected: true,
mandatory: false,
},
Artifact {
key: "rproj.toml",
description: "Records what you picked, so `rproj upgrade` knows what this project is",
category: ArtifactCategory::Core,
requires: &[],
entailed_by: &[],
default_selected: true,
mandatory: false,
},
Artifact {
key: ".gitignore",
description: "Ignores regenerable output - Packages/, sourcemap.json, editor state",
category: ArtifactCategory::Core,
requires: &[],
entailed_by: &[],
default_selected: true,
mandatory: false,
},
Artifact {
key: ".gitattributes",
description: "Pins the working tree to LF, so a fresh Windows clone still passes its own format check",
category: ArtifactCategory::Core,
requires: &[],
entailed_by: &[],
default_selected: true,
mandatory: false,
},
Artifact {
key: "wally.toml",
description: "Wally manifest, split by realm",
category: ArtifactCategory::Dependencies,
requires: &[Requirement::Workflow(Workflow::Wally)],
entailed_by: &[Entailment {
when: Requirement::AnyPackage,
because: "the packages you picked are installed from it",
}],
default_selected: true,
mandatory: false,
},
Artifact {
key: "modules",
description: "Vendored packages as git submodules, with generated link files",
category: ArtifactCategory::Dependencies,
requires: &[Requirement::Workflow(Workflow::GitSubmodules)],
entailed_by: &[Entailment {
when: Requirement::AnyPackage,
because: "the packages you picked are vendored into it",
}],
default_selected: true,
mandatory: false,
},
Artifact {
key: "selene.toml",
description: "Selene lint configuration",
category: ArtifactCategory::Quality,
requires: &[Requirement::Tool("selene")],
entailed_by: &[Entailment {
when: Requirement::Tool("selene"),
because: "selene defaults to the Lua 5.1 std, so every Roblox global lints as undefined",
}],
default_selected: true,
mandatory: false,
},
Artifact {
key: "stylua.toml",
description: "StyLua formatting configuration",
category: ArtifactCategory::Quality,
requires: &[Requirement::Tool("stylua")],
entailed_by: &[],
default_selected: true,
mandatory: false,
},
Artifact {
key: ".luaurc",
description: "Puts Luau in strict mode, so type errors are errors",
category: ArtifactCategory::Quality,
requires: &[],
entailed_by: &[],
default_selected: true,
mandatory: false,
},
Artifact {
key: "sourcemap.json",
description: "Maps files to Roblox instances, so requires resolve in your editor",
category: ArtifactCategory::Quality,
requires: &[Requirement::Tool("rojo")],
entailed_by: &[],
default_selected: true,
mandatory: false,
},
Artifact {
key: "tests",
description: "Test folders with a passing example spec per realm, typed for luau-lsp",
category: ArtifactCategory::Testing,
requires: &[Requirement::Package("testez")],
entailed_by: &[],
default_selected: true,
mandatory: false,
},
Artifact {
key: "testez.yml",
description: "Selene standard library covering TestEZ's globals, so specs don't lint as undefined",
category: ArtifactCategory::Testing,
requires: &[Requirement::Package("testez"), Requirement::Tool("selene")],
entailed_by: &[Entailment {
when: Requirement::Tool("selene"),
because: "selene.toml sets std = roblox+testez, and selene will not run without it",
}],
default_selected: true,
mandatory: false,
},
Artifact {
key: "testez-companion.toml",
description: "Lets the TestEZ Companion extension find your specs",
category: ArtifactCategory::Testing,
requires: &[
Requirement::Package("testez"),
Requirement::Extension("testez-companion"),
],
entailed_by: &[Entailment {
when: Requirement::Extension("testez-companion"),
because: "the extension you installed cannot find your specs without it",
}],
default_selected: true,
mandatory: false,
},
Artifact {
key: ".vscode/settings.json",
description: "Points luau-lsp at the sourcemap, enables the Studio bridge, sets the formatter",
category: ArtifactCategory::Editor,
requires: &[Requirement::App("vscode")],
entailed_by: &[],
default_selected: true,
mandatory: false,
},
Artifact {
key: ".lute/check.luau",
description: "One script that runs every quality check you selected - type, lint, format",
category: ArtifactCategory::Automation,
requires: &[Requirement::Tool("lute")],
entailed_by: &[],
default_selected: true,
mandatory: false,
},
Artifact {
key: ".github/workflows/ci.yml",
description: "Runs the same quality script on every push (default off - it changes what a push does)",
category: ArtifactCategory::Automation,
requires: &[Requirement::Artifact(".lute/check.luau")],
entailed_by: &[],
default_selected: false,
mandatory: false,
},
Artifact {
key: "blender",
description: "Starter .blend scene at Roblox's unit scale (1 stud = 0.28 m)",
category: ArtifactCategory::Assets,
requires: &[Requirement::App("blender")],
entailed_by: &[],
default_selected: false,
mandatory: false,
},
Artifact {
key: "figma",
description: "Folder for exported design assets, wired to the Tarmac upload pipeline",
category: ArtifactCategory::Assets,
requires: &[Requirement::App("figma")],
entailed_by: &[],
default_selected: false,
mandatory: false,
},
Artifact {
key: "tarmac.toml",
description: "Tarmac asset-sync configuration",
category: ArtifactCategory::Assets,
requires: &[Requirement::Tool("tarmac")],
entailed_by: &[Entailment {
when: Requirement::Tool("tarmac"),
because: "tarmac has no default config, so `tarmac sync` would have no inputs",
}],
default_selected: false,
mandatory: false,
},
];
pub fn find(key: &str) -> Option<&'static Artifact> {
ARTIFACTS.iter().find(|a| a.key == key)
}
#[derive(Debug, Clone, Copy)]
pub struct Selections<'a> {
pub packages: &'a [String],
pub tools: &'a [String],
pub apps: &'a [String],
pub extensions: &'a [String],
pub workflow: Workflow,
}
impl<'a> Selections<'a> {
fn has(&self, requirement: &Requirement, chosen_artifacts: &[&str]) -> bool {
match requirement {
Requirement::Package(key) => self.packages.iter().any(|p| p == key),
Requirement::Tool(key) => self.tools.iter().any(|t| t == key),
Requirement::App(key) => self.apps.iter().any(|a| a == key),
Requirement::Extension(key) => self.extensions.iter().any(|e| e == key),
Requirement::Workflow(workflow) => self.workflow == *workflow,
Requirement::Artifact(key) => chosen_artifacts.contains(key),
Requirement::AnyPackage => !self.packages.is_empty(),
Requirement::AnyTool => !self.tools.is_empty(),
}
}
fn entails(&self, artifact: &Artifact) -> Option<&'static str> {
artifact
.entailed_by
.iter()
.find(|e| self.has(&e.when, &[]))
.map(|e| e.because)
}
}
pub fn entailed(selections: &Selections) -> Vec<(&'static Artifact, &'static str)> {
offerable(selections)
.into_iter()
.filter(|a| !a.mandatory)
.filter_map(|a| selections.entails(a).map(|why| (a, why)))
.collect()
}
pub fn offered(selections: &Selections) -> Vec<&'static Artifact> {
offerable(selections)
.into_iter()
.filter(|a| !a.mandatory && selections.entails(a).is_none())
.collect()
}
pub fn offerable(selections: &Selections) -> Vec<&'static Artifact> {
let candidate_keys: Vec<&str> = ARTIFACTS
.iter()
.filter(|a| {
a.requires
.iter()
.filter(|r| !matches!(r, Requirement::Artifact(_)))
.all(|r| selections.has(r, &[]))
})
.map(|a| a.key)
.collect();
ARTIFACTS
.iter()
.filter(|a| a.requires.iter().all(|r| selections.has(r, &candidate_keys)))
.collect()
}
pub fn resolve(selections: &Selections, chosen: &[String]) -> Vec<&'static Artifact> {
let mut keys: Vec<&str> = offerable(selections)
.into_iter()
.filter(|a| {
a.mandatory || selections.entails(a).is_some() || chosen.iter().any(|c| c == a.key)
})
.map(|a| a.key)
.collect();
loop {
let surviving = keys.clone();
let before = keys.len();
keys.retain(|key| {
let artifact = find(key).expect("keys come from ARTIFACTS");
artifact
.requires
.iter()
.all(|r| !matches!(r, Requirement::Artifact(_)) || selections.has(r, &surviving))
});
if keys.len() == before {
break;
}
}
ARTIFACTS.iter().filter(|a| keys.contains(&a.key)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn owned(keys: &[&str]) -> Vec<String> {
keys.iter().map(|s| s.to_string()).collect()
}
#[derive(Clone)]
struct Answers {
packages: Vec<String>,
tools: Vec<String>,
apps: Vec<String>,
extensions: Vec<String>,
workflow: Workflow,
}
impl Answers {
fn new() -> Self {
Self {
packages: Vec::new(),
tools: Vec::new(),
apps: Vec::new(),
extensions: Vec::new(),
workflow: Workflow::Wally,
}
}
fn packages(mut self, keys: &[&str]) -> Self {
self.packages = owned(keys);
self
}
fn tools(mut self, keys: &[&str]) -> Self {
self.tools = owned(keys);
self
}
fn apps(mut self, keys: &[&str]) -> Self {
self.apps = owned(keys);
self
}
fn extensions(mut self, keys: &[&str]) -> Self {
self.extensions = owned(keys);
self
}
fn workflow(mut self, workflow: Workflow) -> Self {
self.workflow = workflow;
self
}
fn selections(&self) -> Selections<'_> {
Selections {
packages: &self.packages,
tools: &self.tools,
apps: &self.apps,
extensions: &self.extensions,
workflow: self.workflow,
}
}
}
fn keys_of(artifacts: &[&'static Artifact]) -> Vec<&'static str> {
artifacts.iter().map(|a| a.key).collect()
}
#[test]
fn every_key_is_unique() {
let mut keys: Vec<&str> = ARTIFACTS.iter().map(|a| a.key).collect();
let before = keys.len();
keys.sort_unstable();
keys.dedup();
assert_eq!(before, keys.len(), "duplicate artifact key");
}
#[test]
fn every_artifact_requirement_resolves() {
for artifact in ARTIFACTS {
for requirement in artifact.requires {
if let Requirement::Artifact(key) = requirement {
assert!(
find(key).is_some(),
"{} requires unknown artifact {key}",
artifact.key
);
}
}
}
}
#[test]
fn the_artifact_requirement_graph_is_acyclic() {
fn reaches(from: &str, target: &str, depth: usize) -> bool {
assert!(depth < ARTIFACTS.len() + 1, "cycle through {from}");
let Some(artifact) = find(from) else { return false };
artifact.requires.iter().any(|r| match r {
Requirement::Artifact(next) => *next == target || reaches(next, target, depth + 1),
_ => false,
})
}
for artifact in ARTIFACTS {
assert!(
!reaches(artifact.key, artifact.key, 0),
"{} reaches itself",
artifact.key
);
}
}
#[test]
fn mandatory_artifacts_require_nothing() {
for artifact in ARTIFACTS.iter().filter(|a| a.mandatory) {
assert!(
artifact.requires.is_empty(),
"{} is mandatory but has requirements",
artifact.key
);
}
}
#[test]
fn a_minimal_answer_writes_only_the_mandatory_artifacts() {
let answers = Answers::new();
let written = keys_of(&resolve(&answers.selections(), &[]));
assert_eq!(written, ["src", "default.project.json"]);
}
#[test]
fn the_previously_unconditional_artifacts_are_all_optional() {
for key in [
".lute/check.luau",
".github/workflows/ci.yml",
".vscode/settings.json",
".luaurc",
".gitattributes",
"blender",
] {
let artifact = find(key).unwrap_or_else(|| panic!("{key} missing"));
assert!(!artifact.mandatory, "{key} is still mandatory");
}
}
#[test]
fn an_artifact_is_not_offered_when_its_tool_is_absent() {
let answers = Answers::new().tools(&["selene"]);
let offerable = keys_of(&offerable(&answers.selections()));
assert!(offerable.contains(&"selene.toml"), "{offerable:?}");
assert!(!offerable.contains(&"stylua.toml"), "no stylua selected: {offerable:?}");
assert!(!offerable.contains(&".lute/check.luau"), "no lute: {offerable:?}");
}
#[test]
fn an_artifact_with_two_requirements_needs_both() {
let can_offer = |packages: &[&str], tools: &[&str]| {
let answers = Answers::new().packages(packages).tools(tools);
offerable(&answers.selections()).iter().any(|a| a.key == "testez.yml")
};
assert!(can_offer(&["testez"], &["selene"]));
assert!(!can_offer(&["testez"], &[]));
assert!(!can_offer(&[], &["selene"]));
}
#[test]
fn the_workflow_decides_between_wally_and_modules() {
for (workflow, expected, absent) in [
(Workflow::Wally, "wally.toml", "modules"),
(Workflow::GitSubmodules, "modules", "wally.toml"),
] {
let answers = Answers::new().workflow(workflow);
let offerable = keys_of(&offerable(&answers.selections()));
assert!(offerable.contains(&expected), "{workflow:?}: {offerable:?}");
assert!(!offerable.contains(&absent), "{workflow:?}: {offerable:?}");
}
}
#[test]
fn dropping_a_dependency_drops_its_dependent() {
let answers = Answers::new().tools(&["lute"]);
let selections = answers.selections();
let with = keys_of(&resolve(
&selections,
&owned(&[".lute/check.luau", ".github/workflows/ci.yml"]),
));
assert!(with.contains(&".github/workflows/ci.yml"), "{with:?}");
let without = keys_of(&resolve(&selections, &owned(&[".github/workflows/ci.yml"])));
assert!(
!without.contains(&".github/workflows/ci.yml"),
"CI must not survive without its script: {without:?}"
);
}
#[test]
fn no_resolved_artifact_has_an_unmet_requirement() {
for mask in 0u32..(1 << 4) {
let mut answers = Answers::new();
if mask & 1 != 0 {
answers = answers.packages(&["testez"]);
}
if mask & 2 != 0 {
answers = answers.tools(&["selene", "stylua", "lute", "rojo", "tarmac"]);
}
if mask & 4 != 0 {
answers = answers.apps(&["vscode", "blender"]);
}
if mask & 8 != 0 {
answers = answers.extensions(&["testez-companion"]);
}
for workflow in [Workflow::Wally, Workflow::GitSubmodules] {
let answers = Answers { workflow, ..answers.clone() };
let selections = answers.selections();
let everything: Vec<String> =
offerable(&selections).iter().map(|a| a.key.to_string()).collect();
for chosen in [everything, Vec::new()] {
let written = resolve(&selections, &chosen);
let keys = keys_of(&written);
for artifact in &written {
for requirement in artifact.requires {
assert!(
selections.has(requirement, &keys),
"{} written with unmet {requirement:?} (mask {mask}, {workflow:?})",
artifact.key
);
}
}
assert!(keys.contains(&"src"), "mask {mask}");
assert!(keys.contains(&"default.project.json"), "mask {mask}");
}
}
}
}
#[test]
fn every_offered_artifact_is_gated_in_the_scaffolder() {
let scaffolder = include_str!("../commands/new.rs");
let missing: Vec<&str> = ARTIFACTS
.iter()
.filter(|a| !a.mandatory)
.map(|a| a.key)
.filter(|key| !scaffolder.contains(&format!("writes(\"{key}\")")))
.collect();
assert!(
missing.is_empty(),
"offered but never written: {missing:?}"
);
}
#[test]
fn picking_packages_settles_the_manifest_they_install_from() {
let answers = Answers::new().packages(&["testez"]);
let selections = answers.selections();
assert!(
!keys_of(&offered(&selections)).contains(&"wally.toml"),
"must not be offered as a choice: {:?}",
keys_of(&offered(&selections))
);
let (_, why) = entailed(&selections)
.into_iter()
.find(|(a, _)| a.key == "wally.toml")
.expect("reported as entailed, with a reason");
assert!(why.contains("packages you picked"), "{why}");
let written = keys_of(&resolve(&selections, &[]));
assert!(written.contains(&"wally.toml"), "{written:?}");
}
#[test]
fn picking_packages_settles_the_submodule_folder_too() {
let answers = Answers::new()
.packages(&["testez"])
.workflow(Workflow::GitSubmodules);
let written = keys_of(&resolve(&answers.selections(), &[]));
assert!(written.contains(&"modules"), "{written:?}");
assert!(!keys_of(&offered(&answers.selections())).contains(&"modules"));
}
#[test]
fn with_no_packages_the_manifest_is_a_question_again() {
let answers = Answers::new();
let selections = answers.selections();
assert!(keys_of(&offered(&selections)).contains(&"wally.toml"));
assert!(entailed(&selections).is_empty(), "nothing is settled yet");
assert_eq!(
keys_of(&resolve(&selections, &[])),
["src", "default.project.json"],
"declining everything must still be possible"
);
}
#[test]
fn pinning_tools_settles_the_file_that_pins_them() {
let answers = Answers::new().tools(&["rojo"]);
let selections = answers.selections();
assert!(!keys_of(&offered(&selections)).contains(&"rokit.toml"));
assert!(keys_of(&resolve(&selections, &[])).contains(&"rokit.toml"));
let empty = Answers::new();
assert!(keys_of(&offered(&empty.selections())).contains(&"rokit.toml"));
}
#[test]
fn a_pinned_linter_settles_the_config_without_which_it_cannot_run() {
let answers = Answers::new().packages(&["testez"]).tools(&["selene"]);
let selections = answers.selections();
let settled: Vec<&str> = entailed(&selections).iter().map(|(a, _)| a.key).collect();
assert!(settled.contains(&"selene.toml"), "{settled:?}");
assert!(settled.contains(&"testez.yml"), "{settled:?}");
let written = keys_of(&resolve(&selections, &[]));
assert!(written.contains(&"selene.toml"), "{written:?}");
assert!(written.contains(&"testez.yml"), "{written:?}");
}
#[test]
fn a_tool_with_working_defaults_keeps_its_config_optional() {
let answers = Answers::new().tools(&["stylua", "lute", "rojo"]);
let selections = answers.selections();
let offered = keys_of(&offered(&selections));
assert!(offered.contains(&"stylua.toml"), "{offered:?}");
assert!(offered.contains(&".lute/check.luau"), "{offered:?}");
assert!(offered.contains(&"sourcemap.json"), "{offered:?}");
let written = keys_of(&resolve(&selections, &[]));
for key in ["stylua.toml", ".lute/check.luau", "sourcemap.json"] {
assert!(!written.contains(&key), "{key} was declined: {written:?}");
}
}
#[test]
fn the_companion_config_needs_the_companion_extension() {
let without = Answers::new().packages(&["testez"]);
assert!(!keys_of(&offerable(&without.selections())).contains(&"testez-companion.toml"));
let with = Answers::new()
.packages(&["testez"])
.extensions(&["testez-companion"]);
assert!(keys_of(&resolve(&with.selections(), &[])).contains(&"testez-companion.toml"));
}
#[test]
fn offered_and_entailed_never_overlap_and_cover_everything_offerable() {
let answers = Answers::new()
.packages(&["testez"])
.tools(&["selene", "stylua", "lute", "rojo", "tarmac"])
.apps(&["vscode", "blender", "figma"])
.extensions(&["testez-companion"]);
let selections = answers.selections();
let offered = keys_of(&offered(&selections));
let settled: Vec<&str> = entailed(&selections).iter().map(|(a, _)| a.key).collect();
for key in &offered {
assert!(!settled.contains(key), "{key} is both offered and settled");
}
let mut covered: Vec<&str> = offered
.iter()
.chain(settled.iter())
.copied()
.chain(ARTIFACTS.iter().filter(|a| a.mandatory).map(|a| a.key))
.collect();
covered.sort_unstable();
let mut all = keys_of(&offerable(&selections));
all.sort_unstable();
assert_eq!(covered, all, "every offerable artifact must be in one bucket");
}
#[test]
fn no_entailment_depends_on_another_artifact() {
for artifact in ARTIFACTS {
for entailment in artifact.entailed_by {
assert!(
!matches!(entailment.when, Requirement::Artifact(_)),
"{}'s entailment reads an artifact, which is always empty here",
artifact.key
);
}
}
}
#[test]
fn nothing_entailed_can_be_dropped_by_resolution() {
for artifact in ARTIFACTS.iter().filter(|a| !a.entailed_by.is_empty()) {
for requirement in artifact.requires {
assert!(
!matches!(requirement, Requirement::Artifact(_)),
"{} is entailed but depends on another artifact, so resolution could drop it",
artifact.key
);
}
}
}
#[test]
fn mandatory_artifacts_are_not_also_entailed() {
for artifact in ARTIFACTS.iter().filter(|a| a.mandatory) {
assert!(
artifact.entailed_by.is_empty(),
"{} is mandatory, so entailment is dead weight",
artifact.key
);
}
}
#[test]
fn every_entailment_reason_reads_as_a_because_clause() {
for artifact in ARTIFACTS {
for entailment in artifact.entailed_by {
let because = entailment.because;
assert!(!because.is_empty(), "{}", artifact.key);
assert!(
because.chars().next().is_some_and(|c| c.is_lowercase()),
"{}: `{because}` should continue the line, not start a sentence",
artifact.key
);
assert!(
!because.ends_with('.'),
"{}: `{because}` should not end in a full stop",
artifact.key
);
}
}
}
#[test]
fn the_two_deliberately_off_by_default_artifacts_are_off() {
for key in [".github/workflows/ci.yml", "blender"] {
assert!(
!find(key).expect(key).default_selected,
"{key} must not be pre-checked"
);
}
}
}