diff --git a/TODO.txt b/TODO.txt index efed0146..942b91df 100644 --- a/TODO.txt +++ b/TODO.txt @@ -100,5 +100,6 @@ we will probably have to start dynamically generating the x.project.json5 rather [syncback] optimize syncback like we did serve. -[atlas-project.mdc] -we need to remove atlas-project.mdc from the main shared agent rules and migrate it to being manually cloned into newly created projects..... +[sourcemap / luau-lsp] +we need to fix "luau-lsp.sourcemap.generatorCommand": "atlas sourcemap default.project.json5 --output sourcemap.json --include-non-scripts --watch", +and make luau-lsp actually use the 'atlas' cli... \ No newline at end of file diff --git a/src/cli/clone.rs b/src/cli/clone.rs index e815edb1..86ced615 100644 --- a/src/cli/clone.rs +++ b/src/cli/clone.rs @@ -1,22 +1,30 @@ +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::time::Instant; use anyhow::{bail, Context}; use clap::Parser; +use fs_err as fs; use crate::roblox_api; -use super::init::{InitCommand, InitKind}; +use super::init::{setup_git_and_rules, write_if_not_exists, write_template_files, InitKind}; use super::syncback::SyncbackCommand; use super::GlobalOptions; -/// Initializes a new Rojo project from a Roblox place and syncs it back. +/// Initializes a new Rojo project from one or more Roblox places and syncs them back. /// -/// Fetches the experience name from Roblox to auto-generate the project -/// folder. Equivalent to `rojo init` + `rojo syncback --download`. +/// With a single place ID, behaves identically to the original clone flow +/// (init + syncback into `default.project.json5` with `$path: "src"`). +/// +/// With multiple place IDs (must belong to the same universe), creates a +/// multi-place project where each place gets its own `.project.json5` +/// and `/` directory. #[derive(Debug, Parser)] pub struct CloneCommand { - /// The place ID to download and sync back. - pub placeid: u64, + /// One or more place IDs to download and sync back. + #[clap(required = true, num_args = 1..)] + pub placeids: Vec, /// Path to create the project in. If omitted, a directory is /// auto-generated from the experience name. @@ -42,45 +50,27 @@ pub struct CloneCommand { impl CloneCommand { pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { + if self.placeids.len() == 1 { + return self.run_single(global); + } + self.run_multi(global) + } + + fn run_single(self, global: GlobalOptions) -> anyhow::Result<()> { + let place_id = self.placeids[0]; + 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) - } + None => resolve_folder_from_experience(place_id, global.opencloud.as_deref())?, }; let skip_git = self.skip_git; - let init = InitCommand { + let init = super::init::InitCommand { path: path.clone(), kind: self.kind, skip_git, - placeid: Some(self.placeid), + placeid: Some(place_id), skip_rules: self.skip_rules, skip_cd: true, }; @@ -90,7 +80,7 @@ impl CloneCommand { let syncback = SyncbackCommand { project: PathBuf::from("default.project.json5"), input: PathBuf::from("Project.rbxl"), - download: Some(self.placeid), + download: Some(place_id), list: false, dry_run: false, interactive: false, @@ -112,6 +102,267 @@ impl CloneCommand { Ok(()) } + + fn run_multi(self, global: GlobalOptions) -> anyhow::Result<()> { + let auth = roblox_api::resolve_auth(global.opencloud.as_deref())?; + + // Validate all places belong to the same universe. + println!("Validating place IDs..."); + let universe_ids: Vec = self + .placeids + .iter() + .map(|id| { + roblox_api::get_universe_id(*id, &auth) + .with_context(|| format!("Failed to resolve universe for place {id}")) + }) + .collect::>>()?; + + let first_universe = universe_ids[0]; + for (i, uid) in universe_ids.iter().enumerate() { + if *uid != first_universe { + bail!( + "Place {} belongs to universe {} but place {} belongs to universe {}. \ + All place IDs must be from the same experience.", + self.placeids[i], + uid, + self.placeids[0], + first_universe, + ); + } + } + + // Resolve root folder. + let path = match self.path { + Some(p) => p, + None => resolve_folder_from_experience(self.placeids[0], global.opencloud.as_deref())?, + }; + + if path.exists() { + let is_empty = path.read_dir()?.next().is_none(); + if !is_empty { + bail!( + "Directory '{}' is not empty. Please use an empty directory.", + path.display() + ); + } + } + + fs::create_dir_all(&path)?; + + // Fetch place names. + println!("Fetching place names..."); + let place_names = roblox_api::fetch_place_names(&self.placeids, &auth)?; + + let places: Vec = build_place_entries(&self.placeids, &place_names); + + println!("Cloning {} places into '{}':", places.len(), path.display()); + for p in &places { + println!(" {} -> {}.project.json5", p.place_id, p.dir_name); + } + + // Write shared template files (everything except default.project.json5). + let canonical = fs::canonicalize(&path)?; + let project_name = canonical + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("new-project"); + + let exclude: HashSet<&str> = ["default.project.json5"].into(); + write_template_files( + &path, + self.kind, + project_name, + None, + self.skip_git, + &exclude, + )?; + + // Generate a project file for each place. + for entry in &places { + let project_content = generate_project_json5(entry); + let project_path = path.join(format!("{}.project.json5", entry.dir_name)); + write_if_not_exists(&project_path, &project_content)?; + } + + // Git init + cursor rules. + setup_git_and_rules(&path, self.skip_git, self.skip_rules)?; + + // Download all places in parallel. + println!("Downloading {} places...", places.len()); + let download_timer = Instant::now(); + let auth_ref = &auth; + let temp_files: Vec<(u64, anyhow::Result)> = + std::thread::scope(|s| { + let handles: Vec<_> = places + .iter() + .map(|entry| { + let pid = entry.place_id; + s.spawn(move || (pid, roblox_api::download_place(pid, auth_ref))) + }) + .collect(); + handles + .into_iter() + .map(|h| { + h.join() + .unwrap_or_else(|_| (0, Err(anyhow::anyhow!("thread panicked")))) + }) + .collect() + }); + println!( + "Downloaded in {:.02}s", + download_timer.elapsed().as_secs_f32() + ); + + // Collect results, bail on any download failure. + let mut downloaded: HashMap = + HashMap::with_capacity(places.len()); + for (pid, result) in temp_files { + let temp = result.with_context(|| format!("Failed to download place {pid}"))?; + downloaded.insert(pid, temp); + } + + // Syncback each place sequentially. + for entry in &places { + println!( + "Syncing back place {} ({})...", + entry.dir_name, entry.place_id + ); + + let temp = downloaded.remove(&entry.place_id).unwrap(); + let input_path = temp.path().to_path_buf(); + + let syncback = SyncbackCommand { + project: PathBuf::from(format!("{}.project.json5", entry.dir_name)), + input: input_path, + download: None, + list: false, + dry_run: false, + interactive: false, + incremental: false, + sourcemap: false, + working_dir: path.clone(), + }; + + syncback.run(GlobalOptions { + verbosity: global.verbosity, + color: global.color, + opencloud: global.opencloud.clone(), + })?; + } + + if !self.skip_git { + crate::git::git_add_all_and_commit(&path, "syncback"); + } + + println!("Created multi-place project successfully."); + + if !self.skip_cd { + std::env::set_current_dir(&path) + .with_context(|| format!("Failed to cd into {}", path.display()))?; + } + + Ok(()) + } +} + +struct PlaceEntry { + place_id: u64, + dir_name: String, +} + +fn build_place_entries(place_ids: &[u64], names: &HashMap) -> Vec { + let mut entries = Vec::with_capacity(place_ids.len()); + let mut taken: HashSet = HashSet::new(); + + for (i, &pid) in place_ids.iter().enumerate() { + let fallback = || format!("place-{}", i + 1); + + let base = match names.get(&pid) { + Some(api_name) => { + let sanitized = sanitize_place_name(api_name); + if sanitized.is_empty() { + fallback() + } else { + sanitized + } + } + None => fallback(), + }; + + let dir_name = if taken.contains(&base) { + let mut n = 2u32; + loop { + let candidate = format!("{base}-{n}"); + if !taken.contains(&candidate) { + break candidate; + } + n += 1; + } + } else { + base + }; + + taken.insert(dir_name.clone()); + entries.push(PlaceEntry { + place_id: pid, + dir_name, + }); + } + + entries +} + +fn generate_project_json5(entry: &PlaceEntry) -> String { + format!( + "{{\n\ + \tname: \"{name}\",\n\ + \tservePlaceIds: [{id}],\n\ + \tsyncScriptsOnly: true,\n\ + \tglobIgnorePaths: [\"{name}/ServerStorage/RBX_ANIMSAVES/**\", \"{name}/ServerStorage/MoonAnimator2Saves/**\"],\n\ + \tsyncbackRules: {{\n\ + \t\tignoreTrees: [\"ServerStorage/RBX_ANIMSAVES/**\", \"ServerStorage/MoonAnimator2Saves/**\"],\n\ + \t}},\n\ + \ttree: {{\n\ + \t\t$className: \"DataModel\",\n\ + \t\t$path: \"{name}\"\n\ + \t}}\n\ + }}\n", + name = entry.dir_name, + id = entry.place_id, + ) +} + +/// Resolve a folder name from the experience name for a given place ID. +fn resolve_folder_from_experience( + place_id: u64, + opencloud_key: Option<&str>, +) -> anyhow::Result { + let auth = roblox_api::try_resolve_auth(opencloud_key); + let name = match auth { + Some(a) => roblox_api::fetch_experience_name(place_id, &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}"); + Ok(PathBuf::from(folder)) } /// Sanitize a Roblox experience name into a valid folder name. @@ -121,7 +372,6 @@ impl CloneCommand { /// 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; @@ -137,7 +387,6 @@ fn sanitize_name(name: &str) -> String { } } - // Keep only ASCII alphanumeric + spaces, then collapse/join cleaned .split(|c: char| !(c.is_ascii_alphanumeric() || c == ' ')) .collect::() @@ -148,6 +397,19 @@ fn sanitize_name(name: &str) -> String { .to_lowercase() } +/// Sanitize a Roblox place name into a valid directory name. +/// +/// Simpler than experience name sanitization: keeps alphanumeric + spaces, +/// collapses whitespace, lowercases, joins with hyphens. +fn sanitize_place_name(name: &str) -> String { + name.split(|c: char| !(c.is_ascii_alphanumeric() || c == ' ')) + .collect::() + .split_whitespace() + .collect::>() + .join("-") + .to_lowercase() +} + #[cfg(test)] mod tests { use super::*; @@ -189,4 +451,64 @@ mod tests { assert_eq!(sanitize_name("TESTING My Game"), "my-game"); assert_eq!(sanitize_name("My testing Game"), "my-game"); } + + #[test] + fn sanitize_place_name_simple() { + assert_eq!(sanitize_place_name("Game"), "game"); + assert_eq!(sanitize_place_name("Lobby"), "lobby"); + assert_eq!(sanitize_place_name("Main Menu"), "main-menu"); + } + + #[test] + fn sanitize_place_name_special_chars() { + assert_eq!(sanitize_place_name("Game! 🎮"), "game"); + assert_eq!(sanitize_place_name("Test: Level 1"), "test-level-1"); + } + + #[test] + fn build_entries_uses_api_names() { + let mut names = HashMap::new(); + names.insert(111, "Game".to_string()); + names.insert(222, "Lobby".to_string()); + + let entries = build_place_entries(&[111, 222], &names); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].dir_name, "game"); + assert_eq!(entries[0].place_id, 111); + assert_eq!(entries[1].dir_name, "lobby"); + assert_eq!(entries[1].place_id, 222); + } + + #[test] + fn build_entries_dedup_collision() { + let mut names = HashMap::new(); + names.insert(111, "Game".to_string()); + names.insert(222, "Game".to_string()); + + let entries = build_place_entries(&[111, 222], &names); + assert_eq!(entries[0].dir_name, "game"); + assert_eq!(entries[1].dir_name, "game-2"); + } + + #[test] + fn build_entries_fallback_when_no_name() { + let names = HashMap::new(); + + let entries = build_place_entries(&[111, 222], &names); + assert_eq!(entries[0].dir_name, "place-1"); + assert_eq!(entries[1].dir_name, "place-2"); + } + + #[test] + fn generate_project_json5_format() { + let entry = PlaceEntry { + place_id: 12345, + dir_name: "game".to_string(), + }; + let content = generate_project_json5(&entry); + assert!(content.contains("name: \"game\"")); + assert!(content.contains("servePlaceIds: [12345]")); + assert!(content.contains("$path: \"game\"")); + assert!(content.contains("syncScriptsOnly: true")); + } } diff --git a/src/cli/init.rs b/src/cli/init.rs index a9782f9a..9d13cbf4 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::str::FromStr; use std::{ collections::VecDeque, @@ -53,8 +54,6 @@ pub struct InitCommand { impl InitCommand { pub fn run(self) -> anyhow::Result<()> { - let template = self.kind.template(); - let base_path = resolve_path(&self.path); if base_path.exists() { @@ -75,132 +74,18 @@ impl InitCommand { .and_then(|name| name.to_str()) .unwrap_or("new-project"); - let project_params = ProjectParams { - name: project_name.to_owned(), - place_id: self.placeid, - }; + println!("Creating new {:?} project '{}'", self.kind, project_name); - println!( - "Creating new {:?} project '{}'", - self.kind, project_params.name - ); + write_template_files( + &base_path, + self.kind, + project_name, + self.placeid, + self.skip_git, + &HashSet::new(), + )?; - let vfs = Vfs::new(template); - vfs.set_watch_enabled(false); - - let mut queue = VecDeque::with_capacity(8); - for entry in vfs.read_dir("")? { - queue.push_back(entry?.path().to_path_buf()) - } - - while let Some(mut path) = queue.pop_front() { - let metadata = vfs.metadata(&path)?; - if metadata.is_dir() { - fs_err::create_dir(base_path.join(&path))?; - for entry in vfs.read_dir(&path)? { - queue.push_back(entry?.path().to_path_buf()); - } - } else { - let content = vfs.read_to_string_lf_normalized(&path)?; - if let Some(file_stem) = path.file_name().and_then(OsStr::to_str) { - if file_stem == GIT_IGNORE_PLACEHOLDER { - if self.skip_git { - continue; - } else { - path.set_file_name(".gitignore"); - } - } - } - write_if_not_exists( - &base_path.join(&path), - &project_params.render_template(&content), - )?; - } - } - - let rules_dir = base_path.join(".cursor/rules"); - fs::create_dir_all(&rules_dir)?; - write_if_not_exists(&rules_dir.join("atlas-project.mdc"), ATLAS_PROJECT_MDC)?; - - let did_git_init = if !self.skip_git && crate::git::git_repo_root(&base_path).is_none() { - log::debug!("Initializing Git repository..."); - - crate::git::git_init_repo(&base_path).context("Failed to initialize git repository")?; - - true - } else { - !self.skip_git - }; - - if did_git_init { - crate::git::git_add_all_and_commit(&base_path, "Initial commit"); - } - - if !self.skip_rules && did_git_init { - log::debug!("Adding agent submodules..."); - - let submodules: &[(&str, &str)] = &[ - ( - "https://github.com/UserGeneratedLLC/agent-rules.git", - ".cursor/rules/shared", - ), - ( - "https://github.com/UserGeneratedLLC/agent-commands.git", - ".cursor/commands/shared", - ), - ( - "https://github.com/UserGeneratedLLC/agent-skills.git", - ".cursor/skills/shared", - ), - ( - "https://github.com/UserGeneratedLLC/agent-docs.git", - ".cursor/docs/shared", - ), - ]; - - // Shallow-clone all repos in parallel (network I/O). - let clone_results: Vec> = std::thread::scope(|s| { - let handles: Vec<_> = submodules - .iter() - .map(|(url, path)| { - let target = base_path.join(path); - s.spawn(move || crate::git::git_clone_shallow(url, &target)) - }) - .collect(); - handles - .into_iter() - .map(|h| { - h.join() - .unwrap_or_else(|_| Err(anyhow::anyhow!("thread panicked"))) - }) - .collect() - }); - - // Register each clone as a submodule (sequential, index-lock safe). - let mut any_failed = false; - for ((url, path), clone_res) in submodules.iter().zip(clone_results) { - if let Err(e) = clone_res { - log::warn!("Failed to clone {path}: {e}"); - any_failed = true; - continue; - } - if let Err(e) = crate::git::git_submodule_add(&base_path, url, path) { - log::warn!("Failed to register submodule {path}: {e}"); - any_failed = true; - } - } - - if let Err(e) = crate::git::git_config_set(&base_path, "submodule.recurse", "true") { - log::warn!("Failed to set submodule.recurse: {e}"); - any_failed = true; - } - - if !any_failed { - println!("Added agent submodules successfully."); - } - - crate::git::git_add_all_and_commit(&base_path, "Add agent submodules"); - } + setup_git_and_rules(&base_path, self.skip_git, self.skip_rules)?; println!("Created project successfully."); @@ -213,6 +98,154 @@ impl InitCommand { } } +/// Write template files from the baked-in template to `base_path`. +/// +/// `exclude_files` is a set of template filenames to skip (e.g. `"default.project.json5"`). +pub fn write_template_files( + base_path: &Path, + kind: InitKind, + project_name: &str, + place_id: Option, + skip_git: bool, + exclude_files: &HashSet<&str>, +) -> anyhow::Result<()> { + let template = kind.template(); + let project_params = ProjectParams { + name: project_name.to_owned(), + place_id, + }; + + let vfs = Vfs::new(template); + vfs.set_watch_enabled(false); + + let mut queue = VecDeque::with_capacity(8); + for entry in vfs.read_dir("")? { + queue.push_back(entry?.path().to_path_buf()) + } + + while let Some(mut path) = queue.pop_front() { + let metadata = vfs.metadata(&path)?; + if metadata.is_dir() { + fs_err::create_dir_all(base_path.join(&path))?; + for entry in vfs.read_dir(&path)? { + queue.push_back(entry?.path().to_path_buf()); + } + } else { + if let Some(file_name) = path.file_name().and_then(OsStr::to_str) { + if exclude_files.contains(file_name) { + continue; + } + } + + let content = vfs.read_to_string_lf_normalized(&path)?; + if let Some(file_name) = path.file_name().and_then(OsStr::to_str) { + if file_name == GIT_IGNORE_PLACEHOLDER { + if skip_git { + continue; + } else { + path.set_file_name(".gitignore"); + } + } + } + write_if_not_exists( + &base_path.join(&path), + &project_params.render_template(&content), + )?; + } + } + + let rules_dir = base_path.join(".cursor/rules"); + fs::create_dir_all(&rules_dir)?; + write_if_not_exists(&rules_dir.join("atlas-project.mdc"), ATLAS_PROJECT_MDC)?; + + Ok(()) +} + +/// Initialize git repository, make initial commit, and optionally add agent submodules. +pub fn setup_git_and_rules( + base_path: &Path, + skip_git: bool, + skip_rules: bool, +) -> anyhow::Result<()> { + let did_git_init = if !skip_git && crate::git::git_repo_root(base_path).is_none() { + log::debug!("Initializing Git repository..."); + crate::git::git_init_repo(base_path).context("Failed to initialize git repository")?; + true + } else { + !skip_git + }; + + if did_git_init { + crate::git::git_add_all_and_commit(base_path, "Initial commit"); + } + + if !skip_rules && did_git_init { + log::debug!("Adding agent submodules..."); + + let submodules: &[(&str, &str)] = &[ + ( + "https://github.com/UserGeneratedLLC/agent-rules.git", + ".cursor/rules/shared", + ), + ( + "https://github.com/UserGeneratedLLC/agent-commands.git", + ".cursor/commands/shared", + ), + ( + "https://github.com/UserGeneratedLLC/agent-skills.git", + ".cursor/skills/shared", + ), + ( + "https://github.com/UserGeneratedLLC/agent-docs.git", + ".cursor/docs/shared", + ), + ]; + + let clone_results: Vec> = std::thread::scope(|s| { + let handles: Vec<_> = submodules + .iter() + .map(|(url, path)| { + let target = base_path.join(path); + s.spawn(move || crate::git::git_clone_shallow(url, &target)) + }) + .collect(); + handles + .into_iter() + .map(|h| { + h.join() + .unwrap_or_else(|_| Err(anyhow::anyhow!("thread panicked"))) + }) + .collect() + }); + + let mut any_failed = false; + for ((url, path), clone_res) in submodules.iter().zip(clone_results) { + if let Err(e) = clone_res { + log::warn!("Failed to clone {path}: {e}"); + any_failed = true; + continue; + } + if let Err(e) = crate::git::git_submodule_add(base_path, url, path) { + log::warn!("Failed to register submodule {path}: {e}"); + any_failed = true; + } + } + + if let Err(e) = crate::git::git_config_set(base_path, "submodule.recurse", "true") { + log::warn!("Failed to set submodule.recurse: {e}"); + any_failed = true; + } + + if !any_failed { + println!("Added agent submodules successfully."); + } + + crate::git::git_add_all_and_commit(base_path, "Add agent submodules"); + } + + Ok(()) +} + /// The templates we support for initializing a Rojo project. #[derive(Debug, Clone, Copy)] pub enum InitKind { @@ -289,7 +322,7 @@ impl ProjectParams { } /// Write a file if it does not exist yet, otherwise, leave it alone. -fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error> { +pub fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error> { let file_res = OpenOptions::new().write(true).create_new(true).open(path); let mut file = match file_res { diff --git a/src/roblox_api.rs b/src/roblox_api.rs index 815c7838..6fd47a56 100644 --- a/src/roblox_api.rs +++ b/src/roblox_api.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::io::{self, Write as _}; use anyhow::{bail, Context}; @@ -250,6 +251,56 @@ pub fn fetch_experience_name(place_id: u64, auth: &RobloxAuth) -> anyhow::Result Ok(None) } +#[derive(Debug, Deserialize)] +struct PlaceDetail { + #[serde(rename = "placeId")] + place_id: u64, + name: String, +} + +/// Fetch place names for one or more place IDs via the multiget-place-details endpoint. +/// +/// Returns a map of place_id -> place_name. Places that cannot be resolved are omitted. +/// Falls back to no-auth if API key auth fails (games.roblox.com may not support it). +pub fn fetch_place_names( + place_ids: &[u64], + auth: &RobloxAuth, +) -> anyhow::Result> { + let client = reqwest::blocking::Client::new(); + + let mut url = "https://games.roblox.com/v1/games/multiget-place-details?".to_string(); + for (i, id) in place_ids.iter().enumerate() { + if i > 0 { + url.push('&'); + } + url.push_str(&format!("placeIds={id}")); + } + + let try_parse = |resp: reqwest::blocking::Response| -> Option> { + let details: Vec = resp.json().ok()?; + let map: HashMap = details.into_iter().map(|d| (d.place_id, d.name)).collect(); + Some(map) + }; + + let req = apply_auth(client.get(&url), auth); + if let Ok(resp) = req.send() { + if let Some(map) = try_parse(resp) { + return Ok(map); + } + } + + if matches!(auth, RobloxAuth::ApiKey(_)) { + let req = client.get(&url); + if let Ok(resp) = req.send() { + if let Some(map) = try_parse(resp) { + return Ok(map); + } + } + } + + Ok(HashMap::new()) +} + fn apply_auth( req: reqwest::blocking::RequestBuilder, auth: &RobloxAuth,