mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 22:11:38 +00:00
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.
This commit is contained in:
parent
71ea25fe0e
commit
2978dee70d
2 changed files with 242 additions and 1 deletions
232
src/cli/clone.rs
Normal file
232
src/cli/clone.rs
Normal file
|
|
@ -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<PathBuf>,
|
||||
|
||||
/// 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<GameData>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.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("🎮🌊⚡"), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue