#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Requirement {
Capability(&'static str),
App(&'static str),
Extension(&'static str),
Strategy(Strategy),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Strategy {
Wally,
GitSubmodules,
None,
}
impl Requirement {
pub fn describe(&self) -> String {
match self {
Requirement::Capability(key) => format!("the {key} capability"),
Requirement::App(key) => format!("the {key} app"),
Requirement::Extension(key) => format!("the {key} VS Code extension"),
Requirement::Strategy(Strategy::Wally) => "Wally".to_string(),
Requirement::Strategy(Strategy::GitSubmodules) => "git submodules".to_string(),
Requirement::Strategy(Strategy::None) => "no dependency manager".to_string(),
}
}
}
#[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 also_requires: &'static [Requirement],
pub housekeeping: bool,
pub mandatory: bool,
}
pub const ARTIFACTS: &[Artifact] = &[
Artifact {
key: "src",
description: "The shared/server/client source tree",
category: ArtifactCategory::Core,
also_requires: &[],
housekeeping: false,
mandatory: true,
},
Artifact {
key: "default.project.json",
description: "Rojo project definition - the tree Studio syncs against",
category: ArtifactCategory::Core,
also_requires: &[],
housekeeping: false,
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,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "rproj.toml",
description: "Records the decisions this project was built from, so `rproj upgrade` can re-derive them",
category: ArtifactCategory::Core,
also_requires: &[],
housekeeping: true,
mandatory: false,
},
Artifact {
key: ".gitignore",
description: "Ignores regenerable output - Packages/, sourcemap.json, editor state",
category: ArtifactCategory::Core,
also_requires: &[],
housekeeping: 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,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "wally.toml",
description: "Wally manifest, split by realm",
category: ArtifactCategory::Dependencies,
also_requires: &[Requirement::Strategy(Strategy::Wally)],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "modules",
description: "Vendored packages as git submodules, with generated link files",
category: ArtifactCategory::Dependencies,
also_requires: &[Requirement::Strategy(Strategy::GitSubmodules)],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "selene.toml",
description: "Selene lint configuration",
category: ArtifactCategory::Quality,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "stylua.toml",
description: "StyLua formatting configuration",
category: ArtifactCategory::Quality,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: ".luaurc",
description: "Puts Luau in strict mode, so type errors are errors",
category: ArtifactCategory::Quality,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "sourcemap.json",
description: "Maps files to Roblox instances, so requires resolve in your editor",
category: ArtifactCategory::Quality,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "tests",
description: "Test folders with a passing example spec per realm, typed for luau-lsp",
category: ArtifactCategory::Testing,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "testez.yml",
description: "Selene standard library covering TestEZ's globals, so specs don't lint as undefined",
category: ArtifactCategory::Testing,
also_requires: &[Requirement::Capability("lint")],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "testez-companion.toml",
description: "Lets the TestEZ Companion extension find your specs",
category: ArtifactCategory::Testing,
also_requires: &[Requirement::Extension("testez-companion")],
housekeeping: false,
mandatory: false,
},
Artifact {
key: ".vscode/settings.json",
description: "Points luau-lsp at the sourcemap, enables the Studio bridge, sets the formatter",
category: ArtifactCategory::Editor,
also_requires: &[Requirement::App("vscode")],
housekeeping: false,
mandatory: false,
},
Artifact {
key: ".lute/check.luau",
description: "One script that runs every quality check you selected - type, lint, format",
category: ArtifactCategory::Automation,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: ".github/workflows/ci.yml",
description: "Runs the same quality script on every push",
category: ArtifactCategory::Automation,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "blender",
description: "Starter .blend scene at Roblox's unit scale (1 stud = 0.28 m)",
category: ArtifactCategory::Assets,
also_requires: &[Requirement::App("blender")],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "figma",
description: "Folder for exported design assets, wired to the Tarmac upload pipeline",
category: ArtifactCategory::Assets,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
Artifact {
key: "tarmac.toml",
description: "Tarmac asset-sync configuration",
category: ArtifactCategory::Assets,
also_requires: &[],
housekeeping: false,
mandatory: false,
},
];
pub fn find(key: &str) -> Option<&'static Artifact> {
ARTIFACTS.iter().find(|a| a.key == key)
}
#[derive(Debug, Clone, Copy)]
pub struct Environment<'a> {
pub apps: &'a [String],
pub extensions: &'a [String],
pub strategy: Strategy,
}
impl<'a> Environment<'a> {
fn has(&self, requirement: &Requirement, capabilities: &[String]) -> bool {
match requirement {
Requirement::Capability(key) => capabilities.iter().any(|c| c == key),
Requirement::App(key) => self.apps.iter().any(|a| a == key),
Requirement::Extension(key) => self.extensions.iter().any(|e| e == key),
Requirement::Strategy(strategy) => self.strategy == *strategy,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reason {
Mandatory,
Housekeeping,
Capability(String),
Strategy(Strategy),
Pins(usize),
}
impl Reason {
pub fn describe(&self) -> String {
match self {
Reason::Mandatory => "every Rojo project has this".to_string(),
Reason::Housekeeping => "written for every project".to_string(),
Reason::Capability(key) => format!("you chose {key}"),
Reason::Strategy(Strategy::Wally) => "this project uses Wally".to_string(),
Reason::Strategy(Strategy::GitSubmodules) => {
"this project vendors packages as git submodules".to_string()
}
Reason::Strategy(Strategy::None) => "no dependency manager".to_string(),
Reason::Pins(n) => {
format!("pins {n} tool version{} so teammates get the same ones", if *n == 1 { "" } else { "s" })
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Planned {
pub key: &'static str,
pub reason: Reason,
}
pub fn plan(
environment: &Environment,
capability_keys: &[String],
derived: &[String],
pinned_tools: &[String],
dropped: &[String],
) -> Vec<Planned> {
let mut planned = Vec::new();
for artifact in ARTIFACTS {
if !artifact.mandatory && dropped.iter().any(|d| d == artifact.key) {
continue;
}
if !artifact
.also_requires
.iter()
.all(|r| environment.has(r, capability_keys))
{
continue;
}
let reason = if artifact.mandatory {
Reason::Mandatory
} else if artifact.key == "rokit.toml" {
if pinned_tools.is_empty() {
continue;
}
Reason::Pins(pinned_tools.len())
} else if let Some(key) = capability_that_wrote(artifact.key, capability_keys) {
Reason::Capability(key)
} else if strategy_wants(artifact.key, environment.strategy) {
Reason::Strategy(environment.strategy)
} else if derived.iter().any(|d| d == artifact.key) {
Reason::Housekeeping
} else if artifact.housekeeping {
Reason::Housekeeping
} else {
continue;
};
planned.push(Planned { key: artifact.key, reason });
}
planned
}
fn capability_that_wrote(key: &str, chosen: &[String]) -> Option<String> {
use crate::catalog::capabilities;
for capability in capabilities::offerable(chosen) {
if !chosen.iter().any(|c| c == capability.key) {
continue;
}
for implementation in capability.implementations {
if implementation.artifacts.contains(&key) {
return Some(capability.key.to_string());
}
}
}
None
}
fn strategy_wants(key: &str, strategy: Strategy) -> bool {
matches!(
(key, strategy),
("wally.toml", Strategy::Wally) | ("modules", Strategy::GitSubmodules)
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::capabilities;
fn owned(keys: &[&str]) -> Vec<String> {
keys.iter().map(|s| s.to_string()).collect()
}
fn env(strategy: Strategy) -> (Vec<String>, Vec<String>, Strategy) {
(Vec::new(), Vec::new(), strategy)
}
fn plan_from(
capabilities_chosen: &[&str],
strategy: Strategy,
apps: &[&str],
extensions: &[&str],
) -> Vec<Planned> {
let (apps, extensions) = (owned(apps), owned(extensions));
let environment = Environment { apps: &apps, extensions: &extensions, strategy };
let selected: Vec<(String, Option<String>)> = capabilities_chosen
.iter()
.map(|k| (k.to_string(), None))
.collect();
let derived = capabilities::derive(&selected);
let keys = owned(capabilities_chosen);
plan(&environment, &keys, &derived.artifacts, &derived.tools, &[])
}
fn keys_of(planned: &[Planned]) -> Vec<&'static str> {
planned.iter().map(|p| p.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 mandatory_artifacts_require_nothing() {
for artifact in ARTIFACTS.iter().filter(|a| a.mandatory) {
assert!(artifact.also_requires.is_empty(), "{}", artifact.key);
assert!(!artifact.housekeeping, "{} is both", artifact.key);
}
}
#[test]
fn every_artifact_is_reachable_from_something() {
let strategy_owned = ["wally.toml", "modules"];
for artifact in ARTIFACTS {
if artifact.mandatory || artifact.housekeeping {
continue;
}
if strategy_owned.contains(&artifact.key) || artifact.key == "rokit.toml" {
continue;
}
let claimed = capabilities::CAPABILITIES.iter().any(|c| {
c.implementations
.iter()
.any(|i| i.artifacts.contains(&artifact.key))
});
assert!(
claimed,
"{} is not derived by any capability, so nothing can write it",
artifact.key
);
}
}
#[test]
fn every_capability_artifact_exists() {
for capability in capabilities::CAPABILITIES {
for implementation in capability.implementations {
for key in implementation.artifacts {
assert!(
find(key).is_some(),
"{}/{} names unknown artifact {key}",
capability.key,
implementation.key
);
}
}
}
}
#[test]
fn a_minimal_answer_writes_almost_nothing() {
let keys = keys_of(&plan_from(&[], Strategy::None, &[], &[]));
assert_eq!(
keys,
["src", "default.project.json", "rproj.toml", ".gitignore"],
"the mandatory two plus housekeeping, and nothing else"
);
}
#[test]
fn dropping_the_housekeeping_leaves_only_the_mandatory_two() {
let (apps, extensions, strategy) = env(Strategy::None);
let environment = Environment { apps: &apps, extensions: &extensions, strategy };
let planned = plan(&environment, &[], &[], &[], &owned(&["rproj.toml", ".gitignore"]));
assert_eq!(keys_of(&planned), ["src", "default.project.json"]);
}
#[test]
fn every_planned_artifact_carries_a_reason_naming_its_cause() {
let planned = plan_from(&["lint", "format"], Strategy::Wally, &[], &[]);
let reason = |key: &str| {
planned
.iter()
.find(|p| p.key == key)
.unwrap_or_else(|| panic!("{key} not planned: {:?}", keys_of(&planned)))
.reason
.clone()
};
assert_eq!(reason("src"), Reason::Mandatory);
assert_eq!(reason("selene.toml"), Reason::Capability("lint".into()));
assert_eq!(reason("stylua.toml"), Reason::Capability("format".into()));
assert_eq!(reason("wally.toml"), Reason::Strategy(Strategy::Wally));
assert_eq!(reason(".gitignore"), Reason::Housekeeping);
}
#[test]
fn reasons_read_as_clauses_after_the_key() {
assert_eq!(Reason::Capability("lint".into()).describe(), "you chose lint");
assert_eq!(Reason::Strategy(Strategy::Wally).describe(), "this project uses Wally");
for reason in [Reason::Mandatory, Reason::Housekeeping] {
let text = reason.describe();
assert!(text.chars().next().is_some_and(|c| c.is_lowercase()), "{text}");
assert!(!text.ends_with('.'), "{text}");
}
}
#[test]
fn the_testez_selene_library_needs_both_capabilities() {
let with = plan_from(&["test", "lint"], Strategy::Wally, &[], &[]);
assert!(keys_of(&with).contains(&"testez.yml"), "{:?}", keys_of(&with));
let without = plan_from(&["test"], Strategy::Wally, &[], &[]);
assert!(!keys_of(&without).contains(&"testez.yml"), "{:?}", keys_of(&without));
assert!(keys_of(&without).contains(&"tests"));
}
#[test]
fn the_companion_config_needs_the_companion_extension() {
let without = plan_from(&["test"], Strategy::Wally, &[], &[]);
assert!(!keys_of(&without).contains(&"testez-companion.toml"));
let with = plan_from(&["test"], Strategy::Wally, &[], &["testez-companion"]);
assert!(keys_of(&with).contains(&"testez-companion.toml"));
}
#[test]
fn the_blender_scene_needs_blender_installed() {
let without = plan_from(&["assets-3d"], Strategy::None, &[], &[]);
assert!(!keys_of(&without).contains(&"blender"));
let with = plan_from(&["assets-3d"], Strategy::None, &["blender"], &[]);
assert!(keys_of(&with).contains(&"blender"));
}
#[test]
fn the_strategy_decides_between_wally_and_modules() {
for (strategy, expected, absent) in [
(Strategy::Wally, "wally.toml", "modules"),
(Strategy::GitSubmodules, "modules", "wally.toml"),
] {
let keys = keys_of(&plan_from(&[], strategy, &[], &[]));
assert!(keys.contains(&expected), "{strategy:?}: {keys:?}");
assert!(!keys.contains(&absent), "{strategy:?}: {keys:?}");
}
let none = keys_of(&plan_from(&[], Strategy::None, &[], &[]));
assert!(!none.contains(&"wally.toml"), "{none:?}");
assert!(!none.contains(&"modules"), "{none:?}");
}
#[test]
fn ci_cannot_outlive_the_gate_it_runs() {
let with = keys_of(&plan_from(&["gate", "ci"], Strategy::None, &[], &[]));
assert!(with.contains(&".github/workflows/ci.yml"), "{with:?}");
let without = keys_of(&plan_from(&["ci"], Strategy::None, &[], &[]));
assert!(!without.contains(&".github/workflows/ci.yml"), "{without:?}");
}
#[test]
fn no_planned_artifact_has_an_unmet_condition() {
let all: Vec<&str> = capabilities::CAPABILITIES.iter().map(|c| c.key).collect();
for mask in 0u32..(1 << all.len().min(9)) {
let chosen: Vec<&str> = all
.iter()
.enumerate()
.filter(|(i, _)| mask & (1 << i) != 0)
.map(|(_, k)| *k)
.collect();
for strategy in [Strategy::Wally, Strategy::GitSubmodules, Strategy::None] {
for apps in [vec![], vec!["vscode", "blender"]] {
let planned = plan_from(&chosen, strategy, &apps, &["testez-companion"]);
let keys = keys_of(&planned);
let chosen_owned = owned(&chosen);
let (apps_owned, ext) = (owned(&apps), owned(&["testez-companion"]));
let environment =
Environment { apps: &apps_owned, extensions: &ext, strategy };
for entry in &planned {
let artifact = find(entry.key).expect("from ARTIFACTS");
for requirement in artifact.also_requires {
assert!(
environment.has(requirement, &chosen_owned),
"{} written with unmet {requirement:?} (mask {mask})",
entry.key
);
}
}
assert!(keys.contains(&"src"), "mask {mask}");
assert!(keys.contains(&"default.project.json"), "mask {mask}");
}
}
}
}
}