Enhance project cloning and initialization process

- Updated CloneCommand to support multiple place IDs, allowing the creation of multi-place projects with individual project files for each place.
- Refactored the initialization logic in InitCommand to streamline project setup and ensure proper handling of project templates.
- Added a new function to fetch place names from the Roblox API, improving the user experience during project creation.
- Updated TODO.txt with a new entry for fixing the luau-lsp sourcemap generator command.
This commit is contained in:
Joe 2026-03-06 22:49:32 -06:00
parent cf31e9b914
commit 093e62af8e
4 changed files with 575 additions and 168 deletions

View file

@ -100,5 +100,6 @@ we will probably have to start dynamically generating the x.project.json5 rather
[syncback] [syncback]
optimize syncback like we did serve. optimize syncback like we did serve.
[atlas-project.mdc] [sourcemap / luau-lsp]
we need to remove atlas-project.mdc from the main shared agent rules and migrate it to being manually cloned into newly created projects..... 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...

View file

@ -1,22 +1,30 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::{bail, Context}; use anyhow::{bail, Context};
use clap::Parser; use clap::Parser;
use fs_err as fs;
use crate::roblox_api; 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::syncback::SyncbackCommand;
use super::GlobalOptions; 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 /// With a single place ID, behaves identically to the original clone flow
/// folder. Equivalent to `rojo init` + `rojo syncback --download`. /// (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 `<name>.project.json5`
/// and `<name>/` directory.
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
pub struct CloneCommand { pub struct CloneCommand {
/// The place ID to download and sync back. /// One or more place IDs to download and sync back.
pub placeid: u64, #[clap(required = true, num_args = 1..)]
pub placeids: Vec<u64>,
/// Path to create the project in. If omitted, a directory is /// Path to create the project in. If omitted, a directory is
/// auto-generated from the experience name. /// auto-generated from the experience name.
@ -42,12 +50,296 @@ pub struct CloneCommand {
impl CloneCommand { impl CloneCommand {
pub fn run(self, global: GlobalOptions) -> anyhow::Result<()> { 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 { let path = match self.path {
Some(p) => p, Some(p) => p,
None => { None => resolve_folder_from_experience(place_id, global.opencloud.as_deref())?,
let auth = roblox_api::try_resolve_auth(global.opencloud.as_deref()); };
let skip_git = self.skip_git;
let init = super::init::InitCommand {
path: path.clone(),
kind: self.kind,
skip_git,
placeid: Some(place_id),
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(place_id),
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(())
}
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<u64> = self
.placeids
.iter()
.map(|id| {
roblox_api::get_universe_id(*id, &auth)
.with_context(|| format!("Failed to resolve universe for place {id}"))
})
.collect::<anyhow::Result<Vec<_>>>()?;
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<PlaceEntry> = 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<tempfile::NamedTempFile>)> =
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<u64, tempfile::NamedTempFile> =
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<u64, String>) -> Vec<PlaceEntry> {
let mut entries = Vec::with_capacity(place_ids.len());
let mut taken: HashSet<String> = 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<PathBuf> {
let auth = roblox_api::try_resolve_auth(opencloud_key);
let name = match auth { let name = match auth {
Some(a) => roblox_api::fetch_experience_name(self.placeid, &a)? Some(a) => roblox_api::fetch_experience_name(place_id, &a)?
.context("Could not fetch experience name from Roblox API")?, .context("Could not fetch experience name from Roblox API")?,
None => bail!( None => bail!(
"No --opencloud API key or Roblox cookie available to fetch experience name. \ "No --opencloud API key or Roblox cookie available to fetch experience name. \
@ -70,48 +362,7 @@ impl CloneCommand {
); );
} }
println!("Using folder: {folder}"); println!("Using folder: {folder}");
PathBuf::from(folder) Ok(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. /// Sanitize a Roblox experience name into a valid folder name.
@ -121,7 +372,6 @@ impl CloneCommand {
/// 3. Keep only ASCII alphanumeric and spaces /// 3. Keep only ASCII alphanumeric and spaces
/// 4. Collapse whitespace, lowercase, join with hyphens /// 4. Collapse whitespace, lowercase, join with hyphens
fn sanitize_name(name: &str) -> String { fn sanitize_name(name: &str) -> String {
// Remove [...] and (...) sections (handles nesting)
let mut cleaned = String::with_capacity(name.len()); let mut cleaned = String::with_capacity(name.len());
let mut bracket_depth: u32 = 0; let mut bracket_depth: u32 = 0;
let mut paren_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 cleaned
.split(|c: char| !(c.is_ascii_alphanumeric() || c == ' ')) .split(|c: char| !(c.is_ascii_alphanumeric() || c == ' '))
.collect::<String>() .collect::<String>()
@ -148,6 +397,19 @@ fn sanitize_name(name: &str) -> String {
.to_lowercase() .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::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
.to_lowercase()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -189,4 +451,64 @@ mod tests {
assert_eq!(sanitize_name("TESTING My Game"), "my-game"); assert_eq!(sanitize_name("TESTING My Game"), "my-game");
assert_eq!(sanitize_name("My testing 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"));
}
} }

View file

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::str::FromStr; use std::str::FromStr;
use std::{ use std::{
collections::VecDeque, collections::VecDeque,
@ -53,8 +54,6 @@ pub struct InitCommand {
impl InitCommand { impl InitCommand {
pub fn run(self) -> anyhow::Result<()> { pub fn run(self) -> anyhow::Result<()> {
let template = self.kind.template();
let base_path = resolve_path(&self.path); let base_path = resolve_path(&self.path);
if base_path.exists() { if base_path.exists() {
@ -75,16 +74,47 @@ impl InitCommand {
.and_then(|name| name.to_str()) .and_then(|name| name.to_str())
.unwrap_or("new-project"); .unwrap_or("new-project");
println!("Creating new {:?} project '{}'", self.kind, project_name);
write_template_files(
&base_path,
self.kind,
project_name,
self.placeid,
self.skip_git,
&HashSet::new(),
)?;
setup_git_and_rules(&base_path, self.skip_git, self.skip_rules)?;
println!("Created project successfully.");
if !self.skip_cd {
std::env::set_current_dir(&base_path)
.with_context(|| format!("Failed to cd into {}", base_path.display()))?;
}
Ok(())
}
}
/// 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<u64>,
skip_git: bool,
exclude_files: &HashSet<&str>,
) -> anyhow::Result<()> {
let template = kind.template();
let project_params = ProjectParams { let project_params = ProjectParams {
name: project_name.to_owned(), name: project_name.to_owned(),
place_id: self.placeid, place_id,
}; };
println!(
"Creating new {:?} project '{}'",
self.kind, project_params.name
);
let vfs = Vfs::new(template); let vfs = Vfs::new(template);
vfs.set_watch_enabled(false); vfs.set_watch_enabled(false);
@ -96,15 +126,21 @@ impl InitCommand {
while let Some(mut path) = queue.pop_front() { while let Some(mut path) = queue.pop_front() {
let metadata = vfs.metadata(&path)?; let metadata = vfs.metadata(&path)?;
if metadata.is_dir() { if metadata.is_dir() {
fs_err::create_dir(base_path.join(&path))?; fs_err::create_dir_all(base_path.join(&path))?;
for entry in vfs.read_dir(&path)? { for entry in vfs.read_dir(&path)? {
queue.push_back(entry?.path().to_path_buf()); queue.push_back(entry?.path().to_path_buf());
} }
} else { } 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)?; let content = vfs.read_to_string_lf_normalized(&path)?;
if let Some(file_stem) = path.file_name().and_then(OsStr::to_str) { if let Some(file_name) = path.file_name().and_then(OsStr::to_str) {
if file_stem == GIT_IGNORE_PLACEHOLDER { if file_name == GIT_IGNORE_PLACEHOLDER {
if self.skip_git { if skip_git {
continue; continue;
} else { } else {
path.set_file_name(".gitignore"); path.set_file_name(".gitignore");
@ -122,21 +158,28 @@ impl InitCommand {
fs::create_dir_all(&rules_dir)?; fs::create_dir_all(&rules_dir)?;
write_if_not_exists(&rules_dir.join("atlas-project.mdc"), ATLAS_PROJECT_MDC)?; 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() { 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..."); log::debug!("Initializing Git repository...");
crate::git::git_init_repo(base_path).context("Failed to initialize git repository")?;
crate::git::git_init_repo(&base_path).context("Failed to initialize git repository")?;
true true
} else { } else {
!self.skip_git !skip_git
}; };
if did_git_init { if did_git_init {
crate::git::git_add_all_and_commit(&base_path, "Initial commit"); crate::git::git_add_all_and_commit(base_path, "Initial commit");
} }
if !self.skip_rules && did_git_init { if !skip_rules && did_git_init {
log::debug!("Adding agent submodules..."); log::debug!("Adding agent submodules...");
let submodules: &[(&str, &str)] = &[ let submodules: &[(&str, &str)] = &[
@ -158,7 +201,6 @@ impl InitCommand {
), ),
]; ];
// Shallow-clone all repos in parallel (network I/O).
let clone_results: Vec<anyhow::Result<()>> = std::thread::scope(|s| { let clone_results: Vec<anyhow::Result<()>> = std::thread::scope(|s| {
let handles: Vec<_> = submodules let handles: Vec<_> = submodules
.iter() .iter()
@ -176,7 +218,6 @@ impl InitCommand {
.collect() .collect()
}); });
// Register each clone as a submodule (sequential, index-lock safe).
let mut any_failed = false; let mut any_failed = false;
for ((url, path), clone_res) in submodules.iter().zip(clone_results) { for ((url, path), clone_res) in submodules.iter().zip(clone_results) {
if let Err(e) = clone_res { if let Err(e) = clone_res {
@ -184,13 +225,13 @@ impl InitCommand {
any_failed = true; any_failed = true;
continue; continue;
} }
if let Err(e) = crate::git::git_submodule_add(&base_path, url, path) { if let Err(e) = crate::git::git_submodule_add(base_path, url, path) {
log::warn!("Failed to register submodule {path}: {e}"); log::warn!("Failed to register submodule {path}: {e}");
any_failed = true; any_failed = true;
} }
} }
if let Err(e) = crate::git::git_config_set(&base_path, "submodule.recurse", "true") { if let Err(e) = crate::git::git_config_set(base_path, "submodule.recurse", "true") {
log::warn!("Failed to set submodule.recurse: {e}"); log::warn!("Failed to set submodule.recurse: {e}");
any_failed = true; any_failed = true;
} }
@ -199,18 +240,10 @@ impl InitCommand {
println!("Added agent submodules successfully."); println!("Added agent submodules successfully.");
} }
crate::git::git_add_all_and_commit(&base_path, "Add agent submodules"); crate::git::git_add_all_and_commit(base_path, "Add agent submodules");
}
println!("Created project successfully.");
if !self.skip_cd {
std::env::set_current_dir(&base_path)
.with_context(|| format!("Failed to cd into {}", base_path.display()))?;
} }
Ok(()) Ok(())
}
} }
/// The templates we support for initializing a Rojo project. /// The templates we support for initializing a Rojo project.
@ -289,7 +322,7 @@ impl ProjectParams {
} }
/// Write a file if it does not exist yet, otherwise, leave it alone. /// 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 file_res = OpenOptions::new().write(true).create_new(true).open(path);
let mut file = match file_res { let mut file = match file_res {

View file

@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::io::{self, Write as _}; use std::io::{self, Write as _};
use anyhow::{bail, Context}; use anyhow::{bail, Context};
@ -250,6 +251,56 @@ pub fn fetch_experience_name(place_id: u64, auth: &RobloxAuth) -> anyhow::Result
Ok(None) 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<HashMap<u64, String>> {
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<HashMap<u64, String>> {
let details: Vec<PlaceDetail> = resp.json().ok()?;
let map: HashMap<u64, String> = 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( fn apply_auth(
req: reqwest::blocking::RequestBuilder, req: reqwest::blocking::RequestBuilder,
auth: &RobloxAuth, auth: &RobloxAuth,