mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-24 15:01: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.
|
//! Defines Rojo's CLI through clap types.
|
||||||
|
|
||||||
mod build;
|
mod build;
|
||||||
|
mod clone;
|
||||||
mod cursor;
|
mod cursor;
|
||||||
mod doc;
|
mod doc;
|
||||||
mod fmt_project;
|
mod fmt_project;
|
||||||
|
|
@ -18,6 +19,7 @@ use clap::Parser;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub use self::build::BuildCommand;
|
pub use self::build::BuildCommand;
|
||||||
|
pub use self::clone::CloneCommand;
|
||||||
pub use self::cursor::CursorCommand;
|
pub use self::cursor::CursorCommand;
|
||||||
pub use self::doc::DocCommand;
|
pub use self::doc::DocCommand;
|
||||||
pub use self::fmt_project::FmtProjectCommand;
|
pub use self::fmt_project::FmtProjectCommand;
|
||||||
|
|
@ -44,6 +46,7 @@ pub struct Options {
|
||||||
impl Options {
|
impl Options {
|
||||||
pub fn run(self) -> anyhow::Result<()> {
|
pub fn run(self) -> anyhow::Result<()> {
|
||||||
match self.subcommand {
|
match self.subcommand {
|
||||||
|
Subcommand::Clone(subcommand) => subcommand.run(self.global),
|
||||||
Subcommand::Init(subcommand) => subcommand.run(),
|
Subcommand::Init(subcommand) => subcommand.run(),
|
||||||
Subcommand::Serve(subcommand) => subcommand.run(),
|
Subcommand::Serve(subcommand) => subcommand.run(),
|
||||||
Subcommand::Build(subcommand) => subcommand.run(),
|
Subcommand::Build(subcommand) => subcommand.run(),
|
||||||
|
|
@ -54,7 +57,9 @@ impl Options {
|
||||||
Subcommand::Doc(subcommand) => subcommand.run(),
|
Subcommand::Doc(subcommand) => subcommand.run(),
|
||||||
Subcommand::Plugin(subcommand) => subcommand.run(),
|
Subcommand::Plugin(subcommand) => subcommand.run(),
|
||||||
Subcommand::Studio(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)]
|
#[derive(Debug, Parser)]
|
||||||
pub enum Subcommand {
|
pub enum Subcommand {
|
||||||
|
Clone(CloneCommand),
|
||||||
Init(InitCommand),
|
Init(InitCommand),
|
||||||
Serve(ServeCommand),
|
Serve(ServeCommand),
|
||||||
Build(BuildCommand),
|
Build(BuildCommand),
|
||||||
|
|
@ -131,6 +137,9 @@ pub enum Subcommand {
|
||||||
Plugin(PluginCommand),
|
Plugin(PluginCommand),
|
||||||
Studio(StudioCommand),
|
Studio(StudioCommand),
|
||||||
Syncback(SyncbackCommand),
|
Syncback(SyncbackCommand),
|
||||||
|
/// Alias for `syncback`.
|
||||||
|
#[clap(hide = true)]
|
||||||
|
Pull(SyncbackCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn resolve_path(path: &Path) -> Cow<'_, Path> {
|
pub(super) fn resolve_path(path: &Path) -> Cow<'_, Path> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue