use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::catalog::artifacts::{self, Environment, Planned};
use crate::catalog::capabilities;
use crate::config::PackageWorkflow;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestRunner {
TestEz,
JestRoblox,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum JestBackend {
#[default]
Studio,
OpenCloud,
}
impl JestBackend {
pub fn key(self) -> &'static str {
match self {
Self::Studio => "studio-cli",
Self::OpenCloud => "open-cloud",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Node {
Strategy,
Packages,
Capabilities,
Files,
}
impl Node {
pub const ALL: [Node; 4] = [
Node::Strategy,
Node::Packages,
Node::Capabilities,
Node::Files,
];
pub fn label(&self) -> &'static str {
match self {
Node::Strategy => "dependencies",
Node::Packages => "packages",
Node::Capabilities => "capabilities",
Node::Files => "files",
}
}
pub fn invalidates(&self) -> &'static [Node] {
match self {
Node::Strategy => &[Node::Packages, Node::Files],
Node::Packages => &[Node::Files],
Node::Capabilities => &[Node::Files],
Node::Files => &[],
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProjectGraph {
#[serde(default)]
pub mode: String,
#[serde(default = "wally")]
pub package_workflow: PackageWorkflow,
#[serde(default)]
pub packages: Vec<String>,
#[serde(default)]
pub dropped: Vec<String>,
#[serde(default)]
pub capabilities: BTreeMap<String, String>,
}
fn wally() -> PackageWorkflow {
PackageWorkflow::Wally
}
impl ProjectGraph {
pub fn choose(&mut self, capability: &str, implementation: Option<&str>) {
let Some(entry) = capabilities::find(capability) else {
return;
};
let resolved = implementation
.and_then(|key| entry.implementation(key))
.unwrap_or_else(|| entry.default_implementation());
self.capabilities
.insert(entry.key.to_string(), resolved.key.to_string());
}
pub fn choices(&self) -> Vec<(String, Option<String>)> {
let mut choices = Vec::new();
for (key, implementation) in &self.capabilities {
let key = key.to_string();
if choices
.iter()
.any(|(existing, _): &(String, Option<String>)| existing == &key)
{
continue;
}
choices.push((key, Some(implementation.clone())));
}
choices
}
pub fn capability_keys(&self) -> Vec<String> {
self.choices().into_iter().map(|(key, _)| key).collect()
}
pub fn package_set(&self) -> BTreeSet<String> {
self.packages.iter().cloned().collect()
}
pub fn derived(&self) -> capabilities::Derived {
capabilities::derive(&self.choices())
}
pub fn test_runner(&self) -> Option<TestRunner> {
let key = match self.capabilities.get("test") {
Some(key) => key,
None if self.packages.iter().any(|package| package == "testez") => {
return Some(TestRunner::TestEz);
}
None => return None,
};
Some(match key.as_str() {
"jest-roblox" | "jest-roblox-open-cloud" => TestRunner::JestRoblox,
_ => TestRunner::TestEz,
})
}
pub fn jest_backend(&self) -> JestBackend {
if self
.capabilities
.get("test")
.is_some_and(|key| key == "jest-roblox-open-cloud")
{
JestBackend::OpenCloud
} else {
JestBackend::Studio
}
}
pub fn testing_is_compatible(&self) -> bool {
self.test_runner() != Some(TestRunner::JestRoblox)
|| self.package_workflow == PackageWorkflow::Wally
}
pub fn remove_incompatible_testing(&mut self) -> bool {
if self.testing_is_compatible() {
return false;
}
self.capabilities.remove("test");
true
}
pub fn tools(&self) -> Vec<String> {
let mut tools = self.derived().tools;
for tool in strategy_tools(self.package_workflow, self.packages.is_empty()) {
if !tools.iter().any(|t| t == tool) {
tools.push((*tool).to_string());
}
}
tools
}
pub fn strategy(&self) -> artifacts::Strategy {
match self.package_workflow {
PackageWorkflow::Wally => artifacts::Strategy::Wally,
PackageWorkflow::GitSubmodules => artifacts::Strategy::GitSubmodules,
PackageWorkflow::None => artifacts::Strategy::None,
}
}
pub fn plan(&self, apps: &[String], _extensions: &[String]) -> Vec<Planned> {
let environment = Environment {
apps,
strategy: self.strategy(),
};
artifacts::plan(
&environment,
&self.capability_keys(),
&self.derived().artifacts,
&self.tools(),
&self.dropped,
)
}
pub fn maintenance_plan(&self) -> Vec<Planned> {
let (apps, extensions) = artifacts::every_machine_requirement();
self.plan(&apps, &extensions)
}
pub fn full_plan(&self, apps: &[String], extensions: &[String]) -> Vec<Planned> {
let mut undropped = self.clone();
undropped.dropped.clear();
undropped.plan(apps, extensions)
}
pub fn invalidate(&mut self, node: Node) {
for stale in node.invalidates() {
match stale {
Node::Strategy => self.package_workflow = PackageWorkflow::Wally,
Node::Packages => self.packages.clear(),
Node::Capabilities => self.capabilities.clear(),
Node::Files => self.dropped.clear(),
}
}
}
}
pub fn strategy_tools(workflow: PackageWorkflow, no_packages: bool) -> &'static [&'static str] {
match workflow {
PackageWorkflow::Wally if !no_packages => &["wally", "wally-package-types"],
_ => &[],
}
}
#[cfg(test)]
mod tests {
use super::*;
fn graph(workflow: PackageWorkflow, packages: &[&str], caps: &[&str]) -> ProjectGraph {
let mut g = ProjectGraph {
mode: "expert".into(),
package_workflow: workflow,
packages: packages.iter().map(|p| p.to_string()).collect(),
..Default::default()
};
for key in caps {
g.choose(key, None);
}
g
}
#[test]
fn changing_the_strategy_invalidates_packages_but_not_capabilities() {
let mut g = graph(PackageWorkflow::Wally, &["react"], &["lint", "format"]);
g.dropped.push(".gitignore".into());
g.invalidate(Node::Strategy);
assert!(g.packages.is_empty(), "packages must be re-asked");
assert!(
g.dropped.is_empty(),
"the file list is downstream of everything"
);
assert_eq!(
g.capability_keys(),
["format", "lint"],
"capabilities are orthogonal"
);
}
#[test]
fn only_the_file_list_is_downstream_of_everything() {
for node in [Node::Strategy, Node::Packages, Node::Capabilities] {
assert!(
node.invalidates().contains(&Node::Files),
"{:?} must invalidate the file list",
node
);
}
assert!(
Node::Files.invalidates().is_empty(),
"nothing is downstream of files"
);
}
#[test]
fn invalidation_only_points_forward() {
for (i, node) in Node::ALL.iter().enumerate() {
for stale in node.invalidates() {
let at = Node::ALL.iter().position(|n| n == stale).expect("in ALL");
assert!(
at > i,
"{node:?} invalidates {stale:?}, which comes before it"
);
}
}
}
#[test]
fn changing_capabilities_keeps_the_packages() {
let mut g = graph(PackageWorkflow::Wally, &["reflex"], &["lint"]);
g.invalidate(Node::Capabilities);
assert_eq!(g.packages, ["reflex"], "packages are upstream");
}
#[test]
fn a_capability_choice_is_recorded_concretely() {
let g = graph(PackageWorkflow::None, &[], &["lint"]);
assert_eq!(
g.capabilities.get("lint").map(String::as_str),
Some("selene")
);
}
#[test]
fn an_unknown_capability_is_not_recorded() {
let mut g = ProjectGraph::default();
g.choose("teleportation", None);
assert!(g.capabilities.is_empty());
}
#[test]
fn tools_merge_the_capability_and_strategy_halves() {
let g = graph(PackageWorkflow::Wally, &["reflex"], &["lint", "gate"]);
let tools = g.tools();
assert!(tools.contains(&"selene".to_string()), "{tools:?}");
assert!(tools.contains(&"lute".to_string()), "{tools:?}");
assert!(tools.contains(&"wally".to_string()), "{tools:?}");
assert!(
tools.contains(&"wally-package-types".to_string()),
"{tools:?}"
);
}
#[test]
fn a_wally_project_with_no_packages_pins_no_installer() {
let g = graph(PackageWorkflow::Wally, &[], &[]);
assert!(g.tools().is_empty(), "{:?}", g.tools());
}
#[test]
fn submodule_projects_never_pin_wally() {
let g = graph(PackageWorkflow::GitSubmodules, &["charm"], &["lint"]);
assert!(
!g.tools().iter().any(|t| t.starts_with("wally")),
"{:?}",
g.tools()
);
}
#[test]
fn an_empty_graph_plans_only_the_basics_and_housekeeping() {
let g = graph(PackageWorkflow::None, &[], &[]);
let keys: Vec<&str> = g.plan(&[], &[]).iter().map(|p| p.key).collect();
assert_eq!(
keys,
["src", "default.project.json", "rproj.toml", ".gitignore"]
);
}
#[test]
fn dropping_housekeeping_leaves_only_the_mandatory_two() {
let mut g = graph(PackageWorkflow::None, &[], &[]);
g.dropped = vec!["rproj.toml".into(), ".gitignore".into()];
let keys: Vec<&str> = g.plan(&[], &[]).iter().map(|p| p.key).collect();
assert_eq!(keys, ["src", "default.project.json"]);
}
#[test]
fn the_full_plan_ignores_what_was_dropped() {
let mut g = graph(PackageWorkflow::None, &[], &[]);
g.dropped = vec![".gitignore".into()];
let full: Vec<&str> = g.full_plan(&[], &[]).iter().map(|p| p.key).collect();
assert!(full.contains(&".gitignore"), "{full:?}");
}
#[test]
fn the_graph_round_trips_through_toml() {
let mut g = graph(
PackageWorkflow::GitSubmodules,
&["charm", "vide"],
&["lint", "test"],
);
g.dropped = vec![".gitignore".into()];
let text = toml::to_string_pretty(&g).expect("serialise");
let back: ProjectGraph = toml::from_str(&text).expect("parse back");
assert_eq!(back.package_workflow, PackageWorkflow::GitSubmodules);
assert_eq!(back.packages, ["charm", "vide"]);
assert_eq!(back.dropped, [".gitignore"]);
assert_eq!(back.capability_keys(), ["lint", "test"]);
assert_eq!(
back.capabilities.get("test").map(String::as_str),
Some("testez")
);
}
#[test]
fn changing_away_from_wally_removes_only_jest() {
let mut graph = graph(PackageWorkflow::Wally, &[], &["lint"]);
graph.choose("test", Some("jest-roblox"));
graph.package_workflow = PackageWorkflow::None;
assert!(graph.remove_incompatible_testing());
assert!(graph.capabilities.contains_key("lint"));
assert!(!graph.capabilities.contains_key("test"));
graph.choose("test", Some("testez"));
assert!(!graph.remove_incompatible_testing());
assert_eq!(graph.test_runner(), Some(TestRunner::TestEz));
}
#[test]
fn package_only_legacy_manifests_keep_testez() {
let graph = graph(PackageWorkflow::Wally, &["testez"], &[]);
assert_eq!(graph.test_runner(), Some(TestRunner::TestEz));
}
#[test]
fn selected_test_runner_does_not_plan_its_peer_files() {
let testez = graph(PackageWorkflow::Wally, &[], &["test"]);
let testez_files: Vec<&str> = testez
.plan(&[], &[])
.into_iter()
.map(|artifact| artifact.key)
.collect();
assert!(testez_files.contains(&"tests"));
assert!(!testez_files.contains(&"jest.project.json"));
let mut jest = ProjectGraph {
package_workflow: PackageWorkflow::Wally,
..Default::default()
};
jest.choose("test", Some("jest-roblox"));
let jest_files: Vec<&str> = jest
.plan(&[], &[])
.into_iter()
.map(|artifact| artifact.key)
.collect();
assert!(jest_files.contains(&"jest.project.json"));
assert!(!jest_files.contains(&"testez.yml"));
}
}