rproj 0.20.1

Guided bootstrap-to-game-dev CLI for Roblox: takes a fresh Windows PC to a working Roblox/Luau setup, then scaffolds projects on it
use super::wally_packages::{self, PackageSpec, Realm};

pub struct Guide {
    pub when: &'static str,
    pub context: &'static str,
    pub imports: &'static [&'static str],
    pub example: &'static str,
    pub caveats: &'static str,
}

pub fn find(key: &str) -> Option<Guide> {
    let (when, context, imports, example, caveats): (_, _, &[&str], _, _) = match key {
        "uiLabs" => (
            "Define interactive controls for UI component previews.",
            "Shared ModuleScript returning controls for a UI Labs story.",
            &["uiLabs"],
            "return {\n    Text = UILabs.String(\"Ready\"),\n    Enabled = UILabs.Boolean(true),\n    Count = UILabs.Number(0, 0, 10, 1),\n}",
            "The package provides story helpers, not the Studio plugin. Install UI Labs separately and use these controls in a story; select your UI framework separately.",
        ),
        "react" => (
            "Describe reusable UI components with props and hooks.",
            "Client ModuleScript; return a component for your ReactRoblox root.",
            &["react"],
            "return function()\n    return React.createElement(\"TextLabel\", {\n        Text = \"Ready\",\n        Size = UDim2.fromOffset(160, 40),\n    })\nend",
            "An element is a description, not an Instance. Mount it with ReactRoblox; call hooks only inside components.",
        ),
        "reactRoblox" => (
            "Mount React components into Roblox Instances.",
            "Client LocalScript; select both react and reactRoblox.",
            &["react", "reactRoblox"],
            "local gui = Instance.new(\"ScreenGui\")\ngui.Parent = game:GetService(\"Players\").LocalPlayer.PlayerGui\nlocal root = ReactRoblox.createRoot(gui)\nroot:render(React.createElement(\"TextLabel\", {\n    Text = \"Ready\", Size = UDim2.fromOffset(160, 40),\n}))",
            "Call root:unmount() and gui:Destroy() when this UI is no longer needed.",
        ),
        "vide" => (
            "Build reactive UI without a virtual element tree.",
            "Client LocalScript.",
            &["vide"],
            "local dispose = Vide.root(function()\n    local count = Vide.source(0)\n    Vide.effect(function() print(count()) end)\n    count(1)\nend)\ndispose()",
            "Keep effects and UI inside a root; dispose the root when the screen is removed.",
        ),
        "fusion" => (
            "Model reactive UI state with explicit lifetime scopes.",
            "Client LocalScript.",
            &["fusion"],
            "local scope = Fusion.scoped(Fusion)\nlocal count = scope:Value(0)\ncount:set(1)\nprint(Fusion.peek(count))\nFusion.doCleanup(scope)",
            "This is Fusion 0.3's scoped API, not older 0.2 examples.",
        ),
        "matter" => (
            "Organize gameplay as entities, components, and systems.",
            "Server Script or shared ModuleScript.",
            &["matter"],
            "local Health = Matter.component(\"Health\")\nlocal world = Matter.World.new()\nlocal entity = world:spawn(Health({ value = 100 }))\nprint(world:get(entity, Health).value)\nworld:despawn(entity)",
            "This creates a local world; replication and scheduling systems are separate decisions.",
        ),
        "reflex" => (
            "Centralize immutable state updates and selectors.",
            "Shared ModuleScript; each runtime has independent state.",
            &["reflex"],
            "local producer = Reflex.createProducer({ coins = 0 }, {\n    addCoins = function(state, amount)\n        return { coins = state.coins + amount }\n    end,\n})\nproducer.addCoins(5)\nprint(producer:getState().coins)",
            "Do not mutate the old state inside an action. Replication requires additional setup.",
        ),
        "reactReflex" => (
            "Render Reflex state through React components.",
            "Client component ModuleScript; place beneath ReflexProvider.",
            &["react", "reactReflex"],
            "return function()\n    local coins = ReactReflex.useSelector(function(state)\n        return state.coins\n    end)\n    return React.createElement(\"TextLabel\", {\n        Text = tostring(coins), Size = UDim2.fromOffset(160, 40),\n    })\nend",
            "Select reflex too. Wrap the component in ReactReflex.ReflexProvider with producer = yourProducer.",
        ),
        "charm" => (
            "Share small reactive state without a central reducer.",
            "Shared ModuleScript; server and client state are separate.",
            &["charm"],
            "local count = Charm.atom(0)\nlocal stop = Charm.effect(function() print(count()) end)\ncount(1)\nstop()",
            "Use immutable updates for tables. Dispose effects; sharing a module does not replicate its state.",
        ),
        "charmSync" => (
            "Connect Charm state to your client/server transport.",
            "Client setup snippet; select charm too.",
            &["charm", "charmSync"],
            "local coins = Charm.atom(0)\nCharmSync.client.addSignals({ coins = coins })\n-- Later, during teardown:\nCharmSync.client.removeSignals(\"coins\")",
            "Registration alone sends nothing. Wire server.addSignalsToClient, server.connect, and client.sync through your remotes; validate client requests server-side.",
        ),
        "reactCharm" => (
            "Re-render a React component when Charm state changes.",
            "Client component ModuleScript; select react and charm too.",
            &["react", "charm", "reactCharm"],
            "local coins = Charm.atom(0)\nreturn function()\n    local value = ReactCharm.useSignalState(coins)\n    return React.createElement(\"TextLabel\", {\n        Text = tostring(value), Size = UDim2.fromOffset(160, 40),\n    })\nend",
            "Call this hook inside a mounted React component; expose the atom separately when other modules need to update it.",
        ),
        "videCharm" => (
            "Expose Charm state as a Vide source.",
            "Client LocalScript; select vide and charm too.",
            &["vide", "charm", "videCharm"],
            "local coins = Charm.atom(0)\nlocal dispose = Vide.root(function()\n    local value = VideCharm.useSignalState(coins)\n    Vide.effect(function() print(value()) end)\n    coins(5)\nend)\ndispose()",
            "Create the binding inside a Vide root so its subscription is cleaned up.",
        ),
        "lyra" => (
            "Maintain versioned player data and migration steps.",
            "Server ModuleScript; a migration declaration only.",
            &["lyra"],
            "local addSettings = Lyra.MigrationStep.addFields(\n    \"add-settings\", { Music = true }\n)\nreturn addSettings",
            "A migration does not open a store. Follow createPlayerStore configuration and lifecycle documentation before using real player data.",
        ),
        "profilestore" => (
            "Persist player profiles with session locking.",
            "Server Script only.",
            &["profilestore"],
            "local store = ProfileStore.New(\"ExampleProfiles\", { Coins = 0 })\nprint(store.Name)",
            "Store creation is not a complete player lifecycle. Handle StartSessionAsync failure, reconciliation, PlayerRemoving, and EndSession. Never expose profile data through shared submodules unintentionally.",
        ),
        "scribe" => (
            "Declare typed, replicated profile schemas.",
            "Shared ModuleScript; a schema declaration only.",
            &["scribe"],
            "return {\n    Coins = Scribe.Number(0),\n    Spawn = Scribe.Vector3(Vector3.zero),\n}",
            "Pass the schema as Template to Scribe.new with your ProfileStoreIndex and ProfileKeyPrefix. Typed accessors need the new Luau type solver; follow the full server lifecycle before storing real data.",
        ),
        "testez" => (
            "Add portable BDD-style unit specs.",
            "tests/shared/example.spec.luau ModuleScript; Testing = TestEZ.",
            &[],
            "return function()\n    describe(\"addition\", function()\n        it(\"adds two numbers\", function()\n            expect(2 + 3).to.equal(5)\n        end)\n    end)\nend",
            "Run rproj test from the project directory. These globals are supplied by TestEZ, not by Jest.",
        ),
        "jest" | "jest-globals" => (
            "Run explicit-import specs in Roblox Studio or Open Cloud CI.",
            "tests/shared/example.spec.luau ModuleScript; Testing = Jest Roblox.",
            &["jest-globals"],
            "JestGlobals.describe(\"addition\", function()\n    JestGlobals.it(\"adds two numbers\", function()\n        JestGlobals.expect(2 + 3).toBe(5)\n    end)\nend",
            "Wally only. Run rproj test; local execution needs Studio and the Jest runner plugin. devPackages is mounted in both project files for analysis; test trees exist only in jest.project.json. Choose Local Studio for local tests without CI tests, or Open Cloud for cloud tests locally and in CI. Cloud requires ROBLOX_OPEN_CLOUD_API_KEY, ROBLOX_UNIVERSE_ID, and ROBLOX_PLACE_ID.",
        ),
        "janitor" => (
            "Collect connections and Instances for deterministic cleanup.",
            "Client or server Script.",
            &["janitor"],
            "local cleanup = Janitor.new()\nlocal part = Instance.new(\"Part\")\ncleanup:Add(part, \"Destroy\")\ncleanup:Destroy()",
            "Use one owner per resource lifetime; clean up when that owner is destroyed.",
        ),
        "ripple" => (
            "Animate numbers and Roblox value types.",
            "Client or server Script; manually stepped example.",
            &["ripple"],
            "local motion = Ripple.createMotion(0, { start = false })\nmotion:spring(1)\nprint(motion:step(1 / 60))\nmotion:destroy()",
            "For continuous animation, start the motion or step it each frame; stop/destroy it on teardown.",
        ),
        "reactRipple" => (
            "Bind Ripple animations to React properties.",
            "Client component ModuleScript; select react and ripple too.",
            &["react", "reactRipple"],
            "return function()\n    local transparency, motion = ReactRipple.useMotion(1)\n    React.useEffect(function() motion:spring(0) end, {})\n    return React.createElement(\"Frame\", {\n        BackgroundTransparency = transparency,\n        Size = UDim2.fromOffset(160, 40),\n    })\nend",
            "Hooks require a mounted component. useMotion owns the motion subscription's cleanup.",
        ),
        "prettyReactHooks" => (
            "Reuse common React event, timing, and viewport hooks.",
            "Client component ModuleScript; select react too.",
            &["react", "prettyReactHooks"],
            "return function()\n    local viewport = PrettyReactHooks.useViewport()\n    return React.createElement(\"TextLabel\", {\n        Text = viewport:map(function(size) return tostring(size) end),\n        Size = UDim2.fromOffset(200, 40),\n    })\nend",
            "useViewport returns a React binding, not a plain Vector2. Mount with ReactRoblox.",
        ),
        "videRipple" => (
            "Use Ripple animations as Vide sources.",
            "Client LocalScript; select vide and ripple too.",
            &["vide", "videRipple"],
            "local dispose = Vide.root(function()\n    local value, motion = VideRipple.useMotion(0)\n    motion:spring(1)\n    Vide.effect(function() print(value()) end)\nend)\ntask.delay(1, dispose)",
            "The animation lives inside the Vide root and stops when that root is disposed.",
        ),
        "remo" => (
            "Declare typed remotes in a shared module.",
            "Shared ModuleScript required by both server and client.",
            &["remo"],
            "return Remo.createRemotes({\n    ping = Remo.remote(),\n})",
            "Declarations are not authorization. Validate payloads, permissions, and request rates on the server before acting on client input.",
        ),
        "promise" => (
            "Compose asynchronous work and handle rejection.",
            "Client or server Script.",
            &["promise"],
            "Promise.resolve(5)\n    :andThen(function(value) print(value * 2) end)\n    :catch(function(message) warn(message) end)",
            "Handle rejections. Cancel owned asynchronous work when its caller is destroyed.",
        ),
        "greentea" => (
            "Validate runtime values with typed schemas.",
            "Shared ModuleScript or server boundary validation.",
            &["greentea"],
            "local nameType = gt.build(gt.string())\nnameType:assert(\"Player\")",
            "Assertions throw on invalid data. Handle untrusted input deliberately; a valid type does not imply permission.",
        ),
        "t" => (
            "Validate values at runtime using composable predicates.",
            "Shared ModuleScript or server boundary validation.",
            &["t"],
            "local isScore = t.interface({ score = t.number })\nlocal ok, message = isScore({ score = 10 })\nassert(ok, message)",
            "Validation checks shape, not authorization or rate limits.",
        ),
        "sift" => (
            "Make immutable array and dictionary transformations.",
            "Shared ModuleScript.",
            &["sift"],
            "local original = { coins = 0 }\nlocal updated = Sift.Dictionary.merge(original, { coins = 5 })\nprint(original.coins, updated.coins)",
            "The catalog records this as community-stable, not actively developed. Check nested-copy semantics before modifying nested tables.",
        ),
        _ => return None,
    };
    Some(Guide {
        when,
        context,
        imports,
        example,
        caveats,
    })
}

pub fn import(package: &PackageSpec, submodules: bool) -> String {
    let path = if submodules {
        format!(
            "game:GetService(\"ReplicatedStorage\").modules.{}",
            package.module_name
        )
    } else {
        let (service, mount) = match package.realm {
            Realm::Shared => ("ReplicatedStorage", "packages"),
            Realm::Server => ("ServerScriptService", "serverPackages"),
            Realm::Dev => ("ReplicatedStorage", "devPackages"),
        };
        format!("game:GetService(\"{service}\").{mount}.{}", package.alias())
    };
    format!("local {} = require({path})", package.module_name)
}

pub fn example(guide: &Guide) -> String {
    let mut lines: Vec<_> = guide
        .imports
        .iter()
        .map(|key| {
            import(
                wally_packages::find(key).expect("guide import exists"),
                false,
            )
        })
        .collect();
    lines.push(guide.example.into());
    lines.join("\n")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_existing_package_has_an_example_with_resolved_imports() {
        for package in wally_packages::PACKAGES {
            let guide = find(package.key).expect(package.key);
            assert!(!guide.example.is_empty());
            assert!(!guide.when.is_empty());
            for key in guide.imports {
                assert!(wally_packages::find(key).is_some(), "{key}");
            }
            assert!(!example(&guide).is_empty());
        }
        assert!(
            import(wally_packages::find("profilestore").unwrap(), false)
                .contains("ServerScriptService\").serverPackages.profilestore")
        );
        assert!(
            import(wally_packages::find("jest-globals").unwrap(), false)
                .contains("devPackages.JestGlobals")
        );
        assert!(import(wally_packages::find("charm").unwrap(), true).contains("modules.Charm"));
    }
}