From 2978dee70daf07aab693f0b2deba18a6f423f0b1 Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 6 Feb 2026 19:08:42 -0600 Subject: [PATCH] Add CloneCommand for initializing and syncing Rojo projects - Introduced a new `CloneCommand` struct to facilitate the creation of Rojo projects from Roblox places, combining initialization and syncing in a single command. - Implemented functionality to fetch experience names from the Roblox API and sanitize them for valid folder names. - Added command-line options for specifying place ID, project path, project kind, and options to skip git initialization and cursor rules. - Updated the CLI module to include the new `CloneCommand` and handle its execution within the command structure. - Included tests for the sanitization function to ensure proper handling of various input cases. --- src/cli/clone.rs | 232 +++++++++++++++++++++++++++++++++++++++++++++++ src/cli/mod.rs | 11 ++- 2 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/cli/clone.rs diff --git a/src/cli/clone.rs b/src/cli/clone.rs new file mode 100644 index 00000000..9b1bdf86 --- /dev/null +++ b/src/cli/clone.rs @@ -0,0 +1,232 @@ +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use anyhow::{bail, Context}; +use clap::Parser; +use reqwest::blocking::Client; +use serde::Deserialize; + +use super::init::{InitCommand, InitKind}; +use super::syncback::SyncbackCommand; +use super::GlobalOptions; + +/// Initializes a new Rojo project from a Roblox place and syncs it back. +/// +/// Fetches the experience name from Roblox to auto-generate the project +/// folder. Equivalent to `rojo init` + `rojo syncback --download`. +#[derive(Debug, Parser)] +pub struct CloneCommand { + /// The place ID to download and sync back. + pub placeid: u64, + + /// Path to create the project in. If omitted, a directory is + /// auto-generated from the experience name. + #[clap(long)] + pub path: Option, + + /// The kind of project to create, 'place', 'plugin', or 'model'. + #[clap(long, default_value = "place")] + pub kind: InitKind, + + /// Skips the initialization of a git repository. + #[clap(long)] + pub skip_git: bool, + + /// Skip cloning cursor rules into .cursor directory. + #[clap(long)] + pub skip_rules: bool, +} + +impl CloneCommand { + pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { + let path = match self.path { + Some(p) => p, + None => { + let name = fetch_experience_name(self.placeid)?; + let folder = sanitize_name(&name); + if folder.is_empty() { + bail!( + "Could not derive a folder name from experience '{}'. \ + Use --path to specify one manually.", + name + ); + } + println!("Using folder: {folder}"); + PathBuf::from(folder) + } + }; + + let skip_git = self.skip_git; + + let init = InitCommand { + path: path.clone(), + kind: self.kind, + skip_git, + placeid: Some(self.placeid), + skip_rules: self.skip_rules, + }; + + init.run()?; + + let project_path = path.join("default.project.json5"); + + let syncback = SyncbackCommand { + project: project_path, + input: PathBuf::from("Project.rbxl"), + download: Some(self.placeid), + list: false, + dry_run: false, + interactive: false, + incremental: false, + }; + + syncback.run(global)?; + + // Commit the full project (init + syncback) in one shot + if !skip_git { + let _ = Command::new("git") + .args(["add", "."]) + .current_dir(&path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + let _ = Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&path) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + + Ok(()) + } +} + +#[derive(Debug, Deserialize)] +struct UniverseResponse { + #[serde(rename = "universeId")] + universe_id: u64, +} + +#[derive(Debug, Deserialize)] +struct GamesResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct GameData { + name: String, +} + +/// Resolves a place ID to its experience name via the Roblox API. +fn fetch_experience_name(place_id: u64) -> anyhow::Result { + let client = Client::new(); + + // Place ID -> Universe ID + let universe_url = format!( + "https://apis.roblox.com/universes/v1/places/{place_id}/universe" + ); + let universe: UniverseResponse = client + .get(&universe_url) + .send() + .context("Failed to reach Roblox universe API")? + .json() + .context("Failed to parse universe response")?; + + // Universe ID -> Game info + let games_url = format!( + "https://games.roblox.com/v1/games?universeIds={}", + universe.universe_id + ); + let games: GamesResponse = client + .get(&games_url) + .send() + .context("Failed to reach Roblox games API")? + .json() + .context("Failed to parse games response")?; + + let game = games + .data + .into_iter() + .next() + .context("No game data returned from Roblox")?; + + println!("Experience: {}", game.name); + + Ok(game.name) +} + +/// Sanitize a Roblox experience name into a valid folder name. +/// +/// 1. Strip `[...]` and `(...)` sections +/// 2. Keep only ASCII alphanumeric and spaces +/// 3. Collapse whitespace, lowercase, join with hyphens +fn sanitize_name(name: &str) -> String { + // Remove [...] and (...) sections (handles nesting) + let mut cleaned = String::with_capacity(name.len()); + let mut bracket_depth: u32 = 0; + let mut paren_depth: u32 = 0; + + for ch in name.chars() { + match ch { + '[' => bracket_depth += 1, + ']' if bracket_depth > 0 => bracket_depth -= 1, + '(' => paren_depth += 1, + ')' if paren_depth > 0 => paren_depth -= 1, + _ if bracket_depth == 0 && paren_depth == 0 => cleaned.push(ch), + _ => {} + } + } + + // Keep only ASCII alphanumeric + spaces, then collapse/join + cleaned + .split(|c: char| !(c.is_ascii_alphanumeric() || c == ' ')) + .collect::() + .split_whitespace() + .collect::>() + .join("-") + .to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_typical_name() { + assert_eq!( + sanitize_name("Escape Tsunami For Brainrots!"), + "escape-tsunami-for-brainrots" + ); + } + + #[test] + fn sanitize_brackets_and_parens() { + assert_eq!( + sanitize_name("My Game (UPDATE) [v2.0]"), + "my-game" + ); + } + + #[test] + fn sanitize_emojis() { + assert_eq!( + sanitize_name("Cool Game 🌊🏃⚡"), + "cool-game" + ); + } + + #[test] + fn sanitize_complex() { + assert_eq!( + sanitize_name("some crazy game! (UPDATE) [LOL] 🎮"), + "some-crazy-game" + ); + } + + #[test] + fn sanitize_only_special_chars() { + assert_eq!(sanitize_name("🎮🌊⚡"), ""); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 927c9382..44d1bc1a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,7 @@ //! Defines Rojo's CLI through clap types. mod build; +mod clone; mod cursor; mod doc; mod fmt_project; @@ -18,6 +19,7 @@ use clap::Parser; use thiserror::Error; pub use self::build::BuildCommand; +pub use self::clone::CloneCommand; pub use self::cursor::CursorCommand; pub use self::doc::DocCommand; pub use self::fmt_project::FmtProjectCommand; @@ -44,6 +46,7 @@ pub struct Options { impl Options { pub fn run(self) -> anyhow::Result<()> { match self.subcommand { + Subcommand::Clone(subcommand) => subcommand.run(self.global), Subcommand::Init(subcommand) => subcommand.run(), Subcommand::Serve(subcommand) => subcommand.run(), Subcommand::Build(subcommand) => subcommand.run(), @@ -54,7 +57,9 @@ impl Options { Subcommand::Doc(subcommand) => subcommand.run(), Subcommand::Plugin(subcommand) => subcommand.run(), Subcommand::Studio(subcommand) => subcommand.run(), - Subcommand::Syncback(subcommand) => subcommand.run(self.global), + Subcommand::Syncback(subcommand) | Subcommand::Pull(subcommand) => { + subcommand.run(self.global) + } } } } @@ -120,6 +125,7 @@ pub struct ColorChoiceParseError { #[derive(Debug, Parser)] pub enum Subcommand { + Clone(CloneCommand), Init(InitCommand), Serve(ServeCommand), Build(BuildCommand), @@ -131,6 +137,9 @@ pub enum Subcommand { Plugin(PluginCommand), Studio(StudioCommand), Syncback(SyncbackCommand), + /// Alias for `syncback`. + #[clap(hide = true)] + Pull(SyncbackCommand), } pub(super) fn resolve_path(path: &Path) -> Cow<'_, Path> {