use std::path::{Path, PathBuf}; use anyhow::{bail, Context}; use clap::Parser; use crate::roblox_api; 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, /// Skip changing the working directory into the project after creation. #[clap(long)] pub skip_cd: bool, } impl CloneCommand { pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { let path = match self.path { Some(p) => p, None => { let auth = roblox_api::try_resolve_auth(global.opencloud.as_deref()); let name = match auth { Some(a) => roblox_api::fetch_experience_name(self.placeid, &a)? .context("Could not fetch experience name from Roblox API")?, None => bail!( "No --opencloud API key or Roblox cookie available to fetch experience name. \ Use --path to specify a folder manually, or pass --opencloud ." ), }; 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 ); } if Path::new(&folder).exists() { bail!( "Directory '{}' already exists. \ Remove it or use --path to specify a different location.", folder ); } 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, skip_cd: true, }; init.run()?; let syncback = SyncbackCommand { project: PathBuf::from("default.project.json5"), input: PathBuf::from("Project.rbxl"), download: Some(self.placeid), list: false, dry_run: false, interactive: false, incremental: false, sourcemap: false, working_dir: path.clone(), }; syncback.run(global)?; if !skip_git { crate::git::git_add_all_and_commit(&path, "syncback"); } if !self.skip_cd { std::env::set_current_dir(&path) .with_context(|| format!("Failed to cd into {}", path.display()))?; } Ok(()) } } /// Sanitize a Roblox experience name into a valid folder name. /// /// 1. Strip `[...]` and `(...)` sections /// 2. Remove noise words (e.g. "testing") /// 3. Keep only ASCII alphanumeric and spaces /// 4. 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() .filter(|w| !w.eq_ignore_ascii_case("testing")) .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("🎮🌊⚡"), ""); } #[test] fn sanitize_removes_testing() { assert_eq!(sanitize_name("My Game Testing"), "my-game"); assert_eq!(sanitize_name("TESTING My Game"), "my-game"); assert_eq!(sanitize_name("My testing Game"), "my-game"); } }