mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 14:01:37 +00:00
- Introduced a new fixture `syncback_encoded_names` with various scripts and models demonstrating encoded characters in names, including `What%QUESTION%Module` and `Key%COLON%Script`. - Added tests to validate the correct handling of encoded names during directory conversions, ensuring that the filesystem names match the expected encoded formats. - Updated `change_processor.rs` and `api.rs` to improve file handling and metadata management related to encoded names, enhancing overall robustness and consistency in operations.
5349 lines
209 KiB
Rust
5349 lines
209 KiB
Rust
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
|
||
//! JSON.
|
||
|
||
use std::{
|
||
collections::{HashMap, HashSet},
|
||
fs,
|
||
path::{Path, PathBuf},
|
||
str::FromStr,
|
||
sync::{Arc, Mutex},
|
||
};
|
||
|
||
use futures::{sink::SinkExt, stream::StreamExt};
|
||
use hyper::{body, Body, Method, Request, Response, StatusCode};
|
||
use hyper_tungstenite::{is_upgrade_request, tungstenite::Message, upgrade, HyperWebsocket};
|
||
use opener::OpenError;
|
||
use rbx_dom_weak::{
|
||
types::{Ref, Variant},
|
||
InstanceBuilder, UstrMap, WeakDom,
|
||
};
|
||
|
||
use crate::{
|
||
serve_session::ServeSession,
|
||
snapshot::{InstanceWithMeta, InstigatingSource, PatchSet, PatchUpdate},
|
||
syncback::VISIBLE_SERVICES,
|
||
web::{
|
||
interface::{
|
||
ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse,
|
||
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
|
||
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
|
||
},
|
||
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
|
||
},
|
||
web_api::{InstanceUpdate, RefPatchResponse, SerializeResponse},
|
||
};
|
||
|
||
/// Represents the existing file format for a script/instance on disk.
|
||
/// Used to preserve the current format when doing partial updates from the plugin.
|
||
///
|
||
/// When the plugin sends a script update, it might not include children info.
|
||
/// We check the filesystem to see if this is already a directory (with init file)
|
||
/// or a standalone file, and preserve that format to avoid creating duplicates.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
enum ExistingFileFormat {
|
||
/// No existing file found - use has_children to decide
|
||
None,
|
||
/// Standalone file exists (e.g., Name.luau). Carries the detected file path
|
||
/// so we write back to the same extension (.model.json vs .model.json5).
|
||
Standalone(PathBuf),
|
||
/// Directory with init file exists (e.g., Name/init.luau). Carries the dir path.
|
||
Directory(PathBuf),
|
||
}
|
||
|
||
/// Convert a Variant to a JSON-compatible value for .model.json5 files
|
||
fn variant_to_json(variant: &Variant) -> Option<serde_json::Value> {
|
||
use serde_json::{json, Value};
|
||
|
||
match variant {
|
||
Variant::String(s) => Some(Value::String(s.clone())),
|
||
Variant::Bool(b) => Some(Value::Bool(*b)),
|
||
Variant::Int32(n) => Some(json!(n)),
|
||
Variant::Int64(n) => Some(json!(n)),
|
||
Variant::Float32(n) => Some(json!(n)),
|
||
Variant::Float64(n) => Some(json!(n)),
|
||
Variant::Vector2(v) => Some(json!([v.x, v.y])),
|
||
Variant::Vector3(v) => Some(json!([v.x, v.y, v.z])),
|
||
Variant::Color3(c) => Some(json!([c.r, c.g, c.b])),
|
||
Variant::Color3uint8(c) => Some(json!([c.r, c.g, c.b])),
|
||
Variant::CFrame(cf) => {
|
||
let pos = cf.position;
|
||
let ori = cf.orientation;
|
||
Some(json!({
|
||
"position": [pos.x, pos.y, pos.z],
|
||
"orientation": [
|
||
[ori.x.x, ori.x.y, ori.x.z],
|
||
[ori.y.x, ori.y.y, ori.y.z],
|
||
[ori.z.x, ori.z.y, ori.z.z]
|
||
]
|
||
}))
|
||
}
|
||
Variant::UDim(u) => Some(json!({"scale": u.scale, "offset": u.offset})),
|
||
Variant::UDim2(u) => Some(json!({
|
||
"x": {"scale": u.x.scale, "offset": u.x.offset},
|
||
"y": {"scale": u.y.scale, "offset": u.y.offset}
|
||
})),
|
||
Variant::Enum(e) => {
|
||
// Note: Enum values without type context can only be serialized as numbers.
|
||
// Properties like RunContext should be filtered out before calling this function
|
||
// since their type is encoded in the file suffix instead.
|
||
Some(json!(e.to_u32()))
|
||
}
|
||
Variant::BrickColor(bc) => Some(json!(*bc as u16)),
|
||
Variant::NumberSequence(ns) => {
|
||
let keypoints: Vec<_> = ns
|
||
.keypoints
|
||
.iter()
|
||
.map(|kp| json!({"time": kp.time, "value": kp.value, "envelope": kp.envelope}))
|
||
.collect();
|
||
Some(json!({"keypoints": keypoints}))
|
||
}
|
||
Variant::ColorSequence(cs) => {
|
||
let keypoints: Vec<_> = cs
|
||
.keypoints
|
||
.iter()
|
||
.map(|kp| json!({"time": kp.time, "color": [kp.color.r, kp.color.g, kp.color.b]}))
|
||
.collect();
|
||
Some(json!({"keypoints": keypoints}))
|
||
}
|
||
Variant::NumberRange(nr) => Some(json!({"min": nr.min, "max": nr.max})),
|
||
Variant::Rect(r) => Some(json!({
|
||
"min": [r.min.x, r.min.y],
|
||
"max": [r.max.x, r.max.y]
|
||
})),
|
||
// Skip complex types that don't serialize well to JSON
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub async fn call(serve_session: Arc<ServeSession>, mut request: Request<Body>) -> Response<Body> {
|
||
let service = ApiService::new(serve_session);
|
||
|
||
match (request.method(), request.uri().path()) {
|
||
(&Method::GET, "/api/rojo") => service.handle_api_rojo().await,
|
||
(&Method::GET, path) if path.starts_with("/api/read/") => {
|
||
service.handle_api_read(request).await
|
||
}
|
||
(&Method::GET, path) if path.starts_with("/api/socket/") => {
|
||
if is_upgrade_request(&request) {
|
||
service.handle_api_socket(&mut request).await
|
||
} else {
|
||
msgpack(
|
||
ErrorResponse::bad_request(
|
||
"/api/socket must be called as a websocket upgrade request",
|
||
),
|
||
StatusCode::BAD_REQUEST,
|
||
)
|
||
}
|
||
}
|
||
(&Method::GET, path) if path.starts_with("/api/serialize/") => {
|
||
service.handle_api_serialize(request).await
|
||
}
|
||
(&Method::GET, path) if path.starts_with("/api/ref-patch/") => {
|
||
service.handle_api_ref_patch(request).await
|
||
}
|
||
|
||
(&Method::POST, path) if path.starts_with("/api/open/") => {
|
||
service.handle_api_open(request).await
|
||
}
|
||
(&Method::POST, "/api/write") => service.handle_api_write(request).await,
|
||
|
||
(_method, path) => msgpack(
|
||
ErrorResponse::not_found(format!("Route not found: {}", path)),
|
||
StatusCode::NOT_FOUND,
|
||
),
|
||
}
|
||
}
|
||
|
||
pub struct ApiService {
|
||
serve_session: Arc<ServeSession>,
|
||
suppressed_paths: Arc<Mutex<HashMap<PathBuf, usize>>>,
|
||
}
|
||
|
||
/// Derives the directory name from a standalone script's filesystem path.
|
||
///
|
||
/// Uses `file_stem()` with script suffix stripping instead of the decoded
|
||
/// instance name, so that Windows-invalid character encoding (e.g., `%3F`
|
||
/// for `?`) is preserved.
|
||
///
|
||
/// Examples:
|
||
/// - `What%3F.server.luau` → `What%3F`
|
||
/// - `MyModule.luau` → `MyModule`
|
||
/// - `Handler.client.lua` → `Handler`
|
||
fn dir_name_from_script_path(standalone_path: &Path) -> &str {
|
||
let file_stem = standalone_path
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or("");
|
||
file_stem
|
||
.strip_suffix(".server")
|
||
.or_else(|| file_stem.strip_suffix(".client"))
|
||
.or_else(|| file_stem.strip_suffix(".plugin"))
|
||
.or_else(|| file_stem.strip_suffix(".local"))
|
||
.or_else(|| file_stem.strip_suffix(".legacy"))
|
||
.unwrap_or(file_stem)
|
||
}
|
||
|
||
/// Derives the directory name from a standalone non-script instance's filesystem path.
|
||
///
|
||
/// Strips compound extensions (`.model.json5`, `.model.json`) or falls back
|
||
/// to `file_stem()` for single-extension types (`.txt`, `.csv`, etc.).
|
||
///
|
||
/// Examples:
|
||
/// - `What%3F.model.json5` → `What%3F`
|
||
/// - `MyPart.model.json` → `MyPart`
|
||
/// - `Greeting.txt` → `Greeting`
|
||
/// - `Translations.csv` → `Translations`
|
||
fn dir_name_from_instance_path(standalone_path: &Path) -> &str {
|
||
let file_name = standalone_path
|
||
.file_name()
|
||
.and_then(|f| f.to_str())
|
||
.unwrap_or("");
|
||
file_name
|
||
.strip_suffix(".model.json5")
|
||
.or_else(|| file_name.strip_suffix(".model.json"))
|
||
.unwrap_or_else(|| {
|
||
standalone_path
|
||
.file_stem()
|
||
.and_then(|s| s.to_str())
|
||
.unwrap_or(file_name)
|
||
})
|
||
}
|
||
|
||
impl ApiService {
|
||
pub fn new(serve_session: Arc<ServeSession>) -> Self {
|
||
let suppressed_paths = serve_session.suppressed_paths();
|
||
ApiService {
|
||
serve_session,
|
||
suppressed_paths,
|
||
}
|
||
}
|
||
|
||
/// Register a path in the suppression map so that the ChangeProcessor
|
||
/// ignores the next VFS event for it (counter-based echo suppression).
|
||
/// Each call increments the counter; each suppressed VFS event decrements it.
|
||
fn suppress_path(&self, path: &Path) {
|
||
let mut suppressed = self.suppressed_paths.lock().unwrap();
|
||
// Try to canonicalize (adds \\?\ prefix on Windows, resolves symlinks).
|
||
// For files that don't exist yet, canonicalize the parent and join.
|
||
if let Ok(canonical) = std::fs::canonicalize(path) {
|
||
*suppressed.entry(canonical).or_insert(0) += 1;
|
||
} else if let Some(parent) = path.parent() {
|
||
if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
|
||
if let Some(file_name) = path.file_name() {
|
||
*suppressed
|
||
.entry(canonical_parent.join(file_name))
|
||
.or_insert(0) += 1;
|
||
}
|
||
}
|
||
}
|
||
// Also increment the raw path as fallback.
|
||
*suppressed.entry(path.to_path_buf()).or_insert(0) += 1;
|
||
}
|
||
|
||
/// Get a summary of information about the server
|
||
async fn handle_api_rojo(&self) -> Response<Body> {
|
||
let tree = self.serve_session.tree();
|
||
let root_instance_id = tree.get_root_id();
|
||
|
||
let ignore_hidden_services = self.serve_session.ignore_hidden_services();
|
||
let visible_services = if ignore_hidden_services {
|
||
VISIBLE_SERVICES.iter().map(|s| s.to_string()).collect()
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
|
||
msgpack_ok(&ServerInfoResponse {
|
||
server_version: SERVER_VERSION.to_owned(),
|
||
protocol_version: PROTOCOL_VERSION,
|
||
session_id: self.serve_session.session_id(),
|
||
project_name: self.serve_session.project_name().to_owned(),
|
||
expected_place_ids: self.serve_session.serve_place_ids().cloned(),
|
||
unexpected_place_ids: self.serve_session.blocked_place_ids().cloned(),
|
||
place_id: self.serve_session.place_id(),
|
||
game_id: self.serve_session.game_id(),
|
||
root_instance_id,
|
||
sync_source_only: true,
|
||
ignore_hidden_services,
|
||
visible_services,
|
||
})
|
||
}
|
||
|
||
/// Handle WebSocket upgrade for real-time message streaming
|
||
async fn handle_api_socket(&self, request: &mut Request<Body>) -> Response<Body> {
|
||
let argument = &request.uri().path()["/api/socket/".len()..];
|
||
let input_cursor: u32 = match argument.parse() {
|
||
Ok(v) => v,
|
||
Err(err) => {
|
||
return msgpack(
|
||
ErrorResponse::bad_request(format!("Malformed message cursor: {}", err)),
|
||
StatusCode::BAD_REQUEST,
|
||
);
|
||
}
|
||
};
|
||
|
||
// Upgrade the connection to WebSocket
|
||
let (response, websocket) = match upgrade(request, None) {
|
||
Ok(result) => result,
|
||
Err(err) => {
|
||
return msgpack(
|
||
ErrorResponse::internal_error(format!("WebSocket upgrade failed: {}", err)),
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
);
|
||
}
|
||
};
|
||
|
||
let serve_session = Arc::clone(&self.serve_session);
|
||
|
||
// Spawn a task to handle the WebSocket connection
|
||
tokio::spawn(async move {
|
||
if let Err(e) =
|
||
handle_websocket_subscription(serve_session, websocket, input_cursor).await
|
||
{
|
||
log::error!("Error in websocket subscription: {}", e);
|
||
}
|
||
});
|
||
|
||
response
|
||
}
|
||
|
||
async fn handle_api_write(&self, request: Request<Body>) -> Response<Body> {
|
||
let session_id = self.serve_session.session_id();
|
||
let tree_mutation_sender = self.serve_session.tree_mutation_sender();
|
||
|
||
let body = body::to_bytes(request.into_body()).await.unwrap();
|
||
|
||
let request: WriteRequest = match deserialize_msgpack(&body) {
|
||
Ok(request) => request,
|
||
Err(err) => {
|
||
return msgpack(
|
||
ErrorResponse::bad_request(format!("Invalid body: {}", err)),
|
||
StatusCode::BAD_REQUEST,
|
||
);
|
||
}
|
||
};
|
||
|
||
if request.session_id != session_id {
|
||
return msgpack(
|
||
ErrorResponse::bad_request("Wrong session ID"),
|
||
StatusCode::BAD_REQUEST,
|
||
);
|
||
}
|
||
|
||
// Process removed instances (syncback: delete files from Rojo filesystem)
|
||
// Phase 1: Gather paths with the tree lock held.
|
||
// Phase 2: Delete files without the lock.
|
||
if !request.removed.is_empty() {
|
||
let removal_actions: Vec<(Ref, Option<(PathBuf, bool)>)> = {
|
||
let tree = self.serve_session.tree();
|
||
request
|
||
.removed
|
||
.iter()
|
||
.map(|&id| {
|
||
let action = tree.get_instance(id).and_then(|inst| {
|
||
inst.metadata().instigating_source.as_ref().and_then(|src| {
|
||
match src {
|
||
crate::snapshot::InstigatingSource::Path(p) => {
|
||
Some((p.clone(), p.is_dir()))
|
||
}
|
||
crate::snapshot::InstigatingSource::ProjectNode {
|
||
name,
|
||
..
|
||
} => {
|
||
log::warn!(
|
||
"Syncback: Cannot remove '{}' — defined in project file",
|
||
name
|
||
);
|
||
None
|
||
}
|
||
}
|
||
})
|
||
});
|
||
(id, action)
|
||
})
|
||
.collect()
|
||
}; // tree lock dropped here
|
||
|
||
// Phase 2: Execute filesystem deletions without the lock
|
||
for (id, action) in removal_actions {
|
||
if let Some((path, is_dir)) = action {
|
||
if !path.exists() {
|
||
log::info!(
|
||
"Syncback: Path already removed (likely parent was deleted): {}",
|
||
path.display()
|
||
);
|
||
continue;
|
||
}
|
||
if is_dir {
|
||
self.suppress_path(&path);
|
||
if let Err(err) = fs::remove_dir_all(&path) {
|
||
log::warn!(
|
||
"Failed to remove directory {:?} for instance {:?}: {}",
|
||
path,
|
||
id,
|
||
err
|
||
);
|
||
} else {
|
||
log::info!("Syncback: Removed directory at {}", path.display());
|
||
}
|
||
} else {
|
||
self.suppress_path(&path);
|
||
if let Err(err) = fs::remove_file(&path) {
|
||
log::warn!(
|
||
"Failed to remove file {:?} for instance {:?}: {}",
|
||
path,
|
||
id,
|
||
err
|
||
);
|
||
} else {
|
||
log::info!("Syncback: Removed file at {}", path.display());
|
||
}
|
||
// Also remove adjacent meta file.
|
||
// Strip known script suffixes (.server, .client, etc.)
|
||
// rather than splitting on dots, so that names containing
|
||
// dots (e.g. "Config.Client.server.luau") resolve correctly.
|
||
if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
|
||
let base_name = file_stem
|
||
.strip_suffix(".server")
|
||
.or_else(|| file_stem.strip_suffix(".client"))
|
||
.or_else(|| file_stem.strip_suffix(".plugin"))
|
||
.or_else(|| file_stem.strip_suffix(".local"))
|
||
.or_else(|| file_stem.strip_suffix(".legacy"))
|
||
.unwrap_or(file_stem);
|
||
if let Some(parent_dir) = path.parent() {
|
||
let meta_path =
|
||
parent_dir.join(format!("{}.meta.json5", base_name));
|
||
if meta_path.exists() {
|
||
self.suppress_path(&meta_path);
|
||
let _ = fs::remove_file(&meta_path);
|
||
log::info!(
|
||
"Syncback: Removed adjacent meta file at {}",
|
||
meta_path.display()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Process added instances (syncback: create files from Studio instances)
|
||
// We hold the tree lock during the entire processing to ensure the cache
|
||
// remains consistent with the tree state being checked.
|
||
// Create a stats tracker for this syncback operation
|
||
let stats = crate::syncback::SyncbackStats::new();
|
||
|
||
if !request.added.is_empty() {
|
||
let tree = self.serve_session.tree();
|
||
// Pre-compute duplicate sibling info in O(N) for efficient path uniqueness checks
|
||
// This makes all subsequent path checks O(d) instead of O(d × s)
|
||
let duplicate_siblings_cache = Self::compute_tree_refs_with_duplicate_siblings(&tree);
|
||
|
||
for added in request.added.values() {
|
||
if let Err(err) =
|
||
self.syncback_added_instance(added, &tree, &duplicate_siblings_cache, &stats)
|
||
{
|
||
log::warn!(
|
||
"Failed to syncback added instance '{}': {}",
|
||
added.name,
|
||
err
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Log summary of any syncback issues
|
||
stats.log_summary();
|
||
|
||
// Persist non-Source property changes to disk (meta/model files).
|
||
// Source property changes are handled by ChangeProcessor when it
|
||
// receives the PatchSet below.
|
||
{
|
||
let tree = self.serve_session.tree();
|
||
for update in &request.updated {
|
||
let prop_names: Vec<&str> = update
|
||
.changed_properties
|
||
.keys()
|
||
.map(|k| k.as_str())
|
||
.collect();
|
||
if !prop_names.is_empty() {
|
||
log::info!(
|
||
"Syncback: Updating properties for instance {:?}: {}",
|
||
update.id,
|
||
prop_names.join(", ")
|
||
);
|
||
}
|
||
if update.changed_name.is_some() {
|
||
log::info!(
|
||
"Syncback: Renaming instance {:?} to {:?}",
|
||
update.id,
|
||
update.changed_name
|
||
);
|
||
}
|
||
|
||
// Write non-Source properties to meta/model files
|
||
if let Err(err) = self.syncback_updated_properties(update, &tree) {
|
||
log::warn!(
|
||
"Failed to persist non-Source properties for instance {:?}: {}",
|
||
update.id,
|
||
err
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let updated_instances = request
|
||
.updated
|
||
.into_iter()
|
||
.map(|update| PatchUpdate {
|
||
id: update.id,
|
||
changed_class_name: update.changed_class_name,
|
||
changed_name: update.changed_name,
|
||
changed_properties: update.changed_properties,
|
||
changed_metadata: None,
|
||
})
|
||
.collect();
|
||
|
||
tree_mutation_sender
|
||
.send(PatchSet {
|
||
removed_instances: request.removed,
|
||
added_instances: Vec::new(),
|
||
updated_instances,
|
||
})
|
||
.unwrap();
|
||
|
||
msgpack_ok(WriteResponse { session_id })
|
||
}
|
||
|
||
/// Syncback an added instance by creating a file in the filesystem.
|
||
/// The file watcher will pick up the change and update Rojo's tree.
|
||
///
|
||
/// Pre-computes which instances have duplicate-named siblings in a RojoTree.
|
||
/// Returns a HashSet of Refs that have at least one sibling with the same name.
|
||
///
|
||
/// This is O(N) where N is the number of instances, and allows subsequent
|
||
/// path uniqueness checks to be O(d) instead of O(d × s) where d=depth, s=siblings.
|
||
fn compute_tree_refs_with_duplicate_siblings(tree: &crate::snapshot::RojoTree) -> HashSet<Ref> {
|
||
use std::collections::VecDeque;
|
||
|
||
let mut has_duplicate_siblings = HashSet::new();
|
||
let mut queue = VecDeque::new();
|
||
queue.push_back(tree.root().id());
|
||
|
||
while let Some(inst_ref) = queue.pop_front() {
|
||
let inst = match tree.get_instance(inst_ref) {
|
||
Some(i) => i,
|
||
None => continue,
|
||
};
|
||
|
||
// Count children by name and collect their refs
|
||
let mut name_to_refs: HashMap<&str, Vec<Ref>> = HashMap::new();
|
||
for child_ref in inst.children() {
|
||
if let Some(child) = tree.get_instance(*child_ref) {
|
||
name_to_refs
|
||
.entry(child.name())
|
||
.or_default()
|
||
.push(*child_ref);
|
||
}
|
||
queue.push_back(*child_ref);
|
||
}
|
||
|
||
// Mark refs that share a name with siblings
|
||
for (_name, refs) in name_to_refs {
|
||
if refs.len() > 1 {
|
||
for r in refs {
|
||
has_duplicate_siblings.insert(r);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
has_duplicate_siblings
|
||
}
|
||
|
||
/// Checks if a path in the Rojo tree is unique using pre-computed duplicate sibling info.
|
||
/// Returns true if the path is unique, false if duplicates exist at any level.
|
||
/// This is O(d) where d is the depth of the instance.
|
||
/// Records any duplicate path issues via the stats tracker.
|
||
fn is_tree_path_unique_with_cache(
|
||
tree: &crate::snapshot::RojoTree,
|
||
target_ref: Ref,
|
||
has_duplicate_siblings: &HashSet<Ref>,
|
||
stats: &crate::syncback::SyncbackStats,
|
||
) -> bool {
|
||
let mut current_ref = target_ref;
|
||
|
||
loop {
|
||
// O(1) lookup instead of O(siblings) counting
|
||
if has_duplicate_siblings.contains(¤t_ref) {
|
||
if let Some(current) = tree.get_instance(current_ref) {
|
||
if let Some(parent) = tree.get_instance(current.parent()) {
|
||
// Build the path for the stats tracker
|
||
let inst_path = format!("{}/{}", parent.name(), current.name());
|
||
stats.record_duplicate_name(&inst_path, current.name());
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
let current = match tree.get_instance(current_ref) {
|
||
Some(inst) => inst,
|
||
None => return false,
|
||
};
|
||
|
||
let parent_ref = current.parent();
|
||
if parent_ref.is_none() {
|
||
// Reached root - path is unique at all levels
|
||
return true;
|
||
}
|
||
|
||
// Move up to parent and check the next level
|
||
current_ref = parent_ref;
|
||
}
|
||
}
|
||
|
||
/// Find a child instance by name under a given parent in the tree.
|
||
/// Returns the Ref of the child if found, None otherwise.
|
||
fn find_child_by_name(
|
||
tree: &crate::snapshot::RojoTree,
|
||
parent_ref: Ref,
|
||
name: &str,
|
||
) -> Option<Ref> {
|
||
let parent = tree.get_instance(parent_ref)?;
|
||
parent
|
||
.children()
|
||
.iter()
|
||
.find(|&&child_ref| {
|
||
tree.get_instance(child_ref)
|
||
.map(|c| c.name() == name)
|
||
.unwrap_or(false)
|
||
})
|
||
.copied()
|
||
}
|
||
|
||
/// This follows the same middleware selection logic as the dedicated syncback
|
||
/// system in `src/syncback/mod.rs` to ensure consistent behavior.
|
||
///
|
||
/// Takes the tree reference and duplicate siblings cache as parameters to ensure
|
||
/// consistency - the cache was computed from this exact tree state.
|
||
///
|
||
/// IMPORTANT: Before creating new files, this function checks if an instance
|
||
/// with the same name already exists in the tree. If so, it updates the existing
|
||
/// file at its `instigating_source.path` instead of creating a new file.
|
||
/// This prevents duplicate file creation when the plugin sends an instance
|
||
/// that already exists on the filesystem.
|
||
fn syncback_added_instance(
|
||
&self,
|
||
added: &crate::web::interface::AddedInstance,
|
||
tree: &crate::snapshot::RojoTree,
|
||
duplicate_siblings_cache: &HashSet<Ref>,
|
||
stats: &crate::syncback::SyncbackStats,
|
||
) -> anyhow::Result<()> {
|
||
use crate::syncback::validate_file_name;
|
||
use anyhow::Context;
|
||
|
||
// Validate the instance name for filesystem safety
|
||
validate_file_name(&added.name).with_context(|| {
|
||
format!(
|
||
"Instance name '{}' is not valid for the filesystem",
|
||
added.name
|
||
)
|
||
})?;
|
||
|
||
// Get the parent Ref (required for top-level added instances)
|
||
let parent_ref = added
|
||
.parent
|
||
.context("Top-level added instance must have a parent")?;
|
||
|
||
// Find the parent instance to get its filesystem path
|
||
let parent_instance = tree
|
||
.get_instance(parent_ref)
|
||
.context("Parent instance not found in Rojo tree")?;
|
||
|
||
// Check if the parent path is unique (no duplicate-named siblings at any level)
|
||
// Uses pre-computed cache for O(d) instead of O(d × s) complexity
|
||
if !Self::is_tree_path_unique_with_cache(tree, parent_ref, duplicate_siblings_cache, stats)
|
||
{
|
||
anyhow::bail!(
|
||
"Cannot sync instance '{}' - parent path contains duplicate-named siblings",
|
||
added.name
|
||
);
|
||
}
|
||
|
||
// CRITICAL: Check if instance already exists in tree before creating new files.
|
||
// This prevents duplicate file creation when the plugin sends an instance
|
||
// that already exists (e.g., user "pulls" an instance that appeared as
|
||
// "to delete" due to duplicate detection issues).
|
||
if let Some(existing_ref) = Self::find_child_by_name(tree, parent_ref, &added.name) {
|
||
if let Some(existing) = tree.get_instance(existing_ref) {
|
||
if let Some(source) = &existing.metadata().instigating_source {
|
||
let existing_path = source.path();
|
||
log::info!(
|
||
"Syncback: Instance '{}' already exists in tree at {}, updating in place",
|
||
added.name,
|
||
existing_path.display()
|
||
);
|
||
// Update the existing instance instead of creating new files
|
||
return self.syncback_update_existing_instance(added, existing_path, stats);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Instance doesn't exist in tree - create new files
|
||
// Get the parent's filesystem path from its metadata.
|
||
// For ProjectNode sources, resolve the $path field relative to the project file.
|
||
let instigating_source = parent_instance
|
||
.metadata()
|
||
.instigating_source
|
||
.as_ref()
|
||
.context("Parent instance has no filesystem path (not synced from filesystem)")?;
|
||
|
||
let parent_path: std::borrow::Cow<'_, std::path::Path> = match instigating_source {
|
||
crate::snapshot::InstigatingSource::Path(p) => std::borrow::Cow::Borrowed(p.as_path()),
|
||
crate::snapshot::InstigatingSource::ProjectNode {
|
||
path: project_path,
|
||
name,
|
||
node,
|
||
..
|
||
} => {
|
||
if let Some(path_node) = &node.path {
|
||
let fs_path = path_node.path();
|
||
let resolved = if fs_path.is_relative() {
|
||
project_path
|
||
.parent()
|
||
.unwrap_or(project_path.as_path())
|
||
.join(fs_path)
|
||
} else {
|
||
fs_path.to_path_buf()
|
||
};
|
||
std::borrow::Cow::Owned(resolved)
|
||
} else {
|
||
anyhow::bail!(
|
||
"Cannot add '{}' — parent '{}' is defined in a project file without $path",
|
||
added.name,
|
||
name
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Determine if parent is a directory or file.
|
||
// ANY instance can have children in Roblox, so if the parent is a
|
||
// standalone file (script, model, txt, csv, etc.), we must convert it
|
||
// to directory format before we can place children inside it.
|
||
let parent_dir = if parent_path.is_dir() {
|
||
parent_path.to_path_buf()
|
||
} else {
|
||
let parent_class = parent_instance.class_name();
|
||
let parent_name = parent_instance.name();
|
||
let containing_dir = parent_path
|
||
.parent()
|
||
.context("Could not get parent directory")?;
|
||
|
||
log::info!(
|
||
"Syncback: Converting standalone {} '{}' at {} to directory format \
|
||
(child '{}' being added)",
|
||
parent_class,
|
||
parent_name,
|
||
parent_path.display(),
|
||
added.name
|
||
);
|
||
|
||
if matches!(
|
||
parent_class.as_str(),
|
||
"ModuleScript" | "Script" | "LocalScript"
|
||
) {
|
||
self.convert_standalone_script_to_directory(
|
||
&parent_path,
|
||
parent_name,
|
||
parent_class.as_str(),
|
||
containing_dir,
|
||
)?
|
||
} else {
|
||
self.convert_standalone_instance_to_directory(
|
||
&parent_path,
|
||
parent_name,
|
||
parent_class.as_str(),
|
||
containing_dir,
|
||
)?
|
||
}
|
||
};
|
||
|
||
// Create the instance at the resolved path
|
||
self.syncback_instance_to_path_with_stats(added, &parent_dir, stats)
|
||
}
|
||
|
||
/// Update an existing instance in place instead of creating new files.
|
||
/// This is called when the plugin sends an "added" instance that already
|
||
/// exists in the tree. We update the existing file at its instigating_source
|
||
/// path to preserve the file format and avoid creating duplicates.
|
||
///
|
||
/// For children, we use the normal syncback path which will check for
|
||
/// existing files via `detect_existing_script_format`.
|
||
fn syncback_update_existing_instance(
|
||
&self,
|
||
added: &crate::web::interface::AddedInstance,
|
||
existing_path: &std::path::Path,
|
||
stats: &crate::syncback::SyncbackStats,
|
||
) -> anyhow::Result<()> {
|
||
use anyhow::Context;
|
||
|
||
let class_name = &added.class_name;
|
||
|
||
// For scripts, update the Source property at the existing path
|
||
match class_name.as_str() {
|
||
"ModuleScript" | "Script" | "LocalScript" => {
|
||
let source = self.get_source_property(added);
|
||
|
||
// Determine the actual file to write based on existing structure
|
||
// If existing_path is a directory, look for init file inside
|
||
let file_path = if existing_path.is_dir() {
|
||
// Find the init file based on class
|
||
let init_name = match class_name.as_str() {
|
||
"ModuleScript" => {
|
||
if existing_path.join("init.luau").exists() {
|
||
"init.luau"
|
||
} else {
|
||
"init.lua"
|
||
}
|
||
}
|
||
"Script" => {
|
||
// Check which init file exists
|
||
if existing_path.join("init.server.luau").exists() {
|
||
"init.server.luau"
|
||
} else if existing_path.join("init.client.luau").exists() {
|
||
"init.client.luau"
|
||
} else if existing_path.join("init.server.lua").exists() {
|
||
"init.server.lua"
|
||
} else {
|
||
"init.server.luau" // Default
|
||
}
|
||
}
|
||
"LocalScript" => {
|
||
// Modern: init.local.luau produces LocalScript
|
||
// Legacy: init.client.lua produces LocalScript (without 'u')
|
||
// Note: init.client.luau produces Script with Client RunContext, NOT LocalScript!
|
||
if existing_path.join("init.local.luau").exists() {
|
||
"init.local.luau"
|
||
} else if existing_path.join("init.client.lua").exists() {
|
||
"init.client.lua"
|
||
} else if existing_path.join("init.local.lua").exists() {
|
||
"init.local.lua"
|
||
} else {
|
||
"init.local.luau" // Default for LocalScript
|
||
}
|
||
}
|
||
_ => unreachable!(),
|
||
};
|
||
existing_path.join(init_name)
|
||
} else {
|
||
// It's already a file path
|
||
existing_path.to_path_buf()
|
||
};
|
||
|
||
self.suppress_path(&file_path);
|
||
fs::write(&file_path, source.as_bytes())
|
||
.with_context(|| format!("Failed to write file: {}", file_path.display()))?;
|
||
|
||
log::info!(
|
||
"Syncback: Updated existing {} at {}",
|
||
class_name,
|
||
file_path.display()
|
||
);
|
||
|
||
// Handle children - they go into the directory
|
||
if !added.children.is_empty() {
|
||
let children_dir = if existing_path.is_dir() {
|
||
existing_path.to_path_buf()
|
||
} else {
|
||
// Standalone scripts cannot have children in Rojo's file format.
|
||
// We need to convert from standalone (e.g., MyScript.server.luau)
|
||
// to directory format (e.g., MyScript/init.server.luau).
|
||
let script_name = added.name.as_str();
|
||
let parent_dir = existing_path.parent().unwrap_or(existing_path);
|
||
let new_dir = parent_dir.join(script_name);
|
||
|
||
// Create the directory
|
||
self.suppress_path(&new_dir);
|
||
fs::create_dir_all(&new_dir).with_context(|| {
|
||
format!(
|
||
"Failed to create directory for script with children: {}",
|
||
new_dir.display()
|
||
)
|
||
})?;
|
||
|
||
// Determine the init file name based on class
|
||
let init_name = match class_name.as_str() {
|
||
"ModuleScript" => "init.luau",
|
||
"Script" => "init.server.luau",
|
||
"LocalScript" => "init.local.luau",
|
||
_ => "init.luau",
|
||
};
|
||
|
||
// Move the script content to init file
|
||
let init_path = new_dir.join(init_name);
|
||
self.suppress_path(&init_path);
|
||
fs::write(&init_path, source.as_bytes()).with_context(|| {
|
||
format!("Failed to write init file: {}", init_path.display())
|
||
})?;
|
||
|
||
// Remove the old standalone file
|
||
if existing_path.exists() && existing_path != init_path {
|
||
self.suppress_path(existing_path);
|
||
fs::remove_file(existing_path).with_context(|| {
|
||
format!(
|
||
"Failed to remove old standalone script: {}",
|
||
existing_path.display()
|
||
)
|
||
})?;
|
||
}
|
||
|
||
log::info!(
|
||
"Syncback: Converted standalone {} to directory format at {}",
|
||
class_name,
|
||
new_dir.display()
|
||
);
|
||
|
||
new_dir
|
||
};
|
||
|
||
// Filter duplicate children
|
||
let inst_path = format!("{}", existing_path.display());
|
||
let unique_children =
|
||
self.filter_duplicate_children(&added.children, &inst_path, stats);
|
||
|
||
// Process children using normal syncback path
|
||
// This will use detect_existing_script_format to check existing files
|
||
for child in &unique_children {
|
||
self.syncback_instance_to_path_with_stats(child, &children_dir, stats)?;
|
||
}
|
||
}
|
||
}
|
||
|
||
// For non-script types, update the appropriate file
|
||
_ => {
|
||
if existing_path.is_dir() {
|
||
// Update init.meta.json5 if needed
|
||
if !added.properties.is_empty() {
|
||
self.write_init_meta_json(existing_path, added)?;
|
||
log::info!(
|
||
"Syncback: Updated existing {} at {}/init.meta.json5",
|
||
class_name,
|
||
existing_path.display()
|
||
);
|
||
}
|
||
|
||
// Handle children
|
||
if !added.children.is_empty() {
|
||
let inst_path = format!("{}", existing_path.display());
|
||
let unique_children =
|
||
self.filter_duplicate_children(&added.children, &inst_path, stats);
|
||
|
||
for child in &unique_children {
|
||
self.syncback_instance_to_path_with_stats(child, existing_path, stats)?;
|
||
}
|
||
}
|
||
} else {
|
||
// It's a standalone file (e.g., .model.json5)
|
||
let content = self.serialize_instance_to_model_json(added)?;
|
||
self.suppress_path(existing_path);
|
||
fs::write(existing_path, &content).with_context(|| {
|
||
format!("Failed to write file: {}", existing_path.display())
|
||
})?;
|
||
log::info!(
|
||
"Syncback: Updated existing {} at {}",
|
||
class_name,
|
||
existing_path.display()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Converts a standalone script file (e.g., `MyModule.luau`) into directory
|
||
/// format (e.g., `MyModule/init.luau`). This is needed when a child is being
|
||
/// added to a standalone script — standalone scripts cannot have children in
|
||
/// Rojo's file format.
|
||
///
|
||
/// Returns the path to the new directory.
|
||
fn convert_standalone_script_to_directory(
|
||
&self,
|
||
standalone_path: &std::path::Path,
|
||
_script_name: &str,
|
||
class_name: &str,
|
||
containing_dir: &std::path::Path,
|
||
) -> anyhow::Result<std::path::PathBuf> {
|
||
use anyhow::Context;
|
||
|
||
// Read the current script content before any modifications
|
||
let source = if standalone_path.exists() {
|
||
fs::read_to_string(standalone_path).unwrap_or_default()
|
||
} else {
|
||
String::new()
|
||
};
|
||
|
||
let dir_name = dir_name_from_script_path(standalone_path);
|
||
|
||
// Create the directory
|
||
let new_dir = containing_dir.join(dir_name);
|
||
self.suppress_path(&new_dir);
|
||
fs::create_dir_all(&new_dir).with_context(|| {
|
||
format!(
|
||
"Failed to create directory for script conversion: {}",
|
||
new_dir.display()
|
||
)
|
||
})?;
|
||
|
||
// Determine the init file name based on class
|
||
let init_name = match class_name {
|
||
"ModuleScript" => "init.luau",
|
||
"Script" => "init.server.luau",
|
||
"LocalScript" => "init.local.luau",
|
||
_ => "init.luau",
|
||
};
|
||
|
||
// Write source content to the init file
|
||
let init_path = new_dir.join(init_name);
|
||
self.suppress_path(&init_path);
|
||
fs::write(&init_path, source.as_bytes())
|
||
.with_context(|| format!("Failed to write init file: {}", init_path.display()))?;
|
||
|
||
// Remove the old standalone file
|
||
if standalone_path.exists() && standalone_path != init_path {
|
||
self.suppress_path(standalone_path);
|
||
fs::remove_file(standalone_path).with_context(|| {
|
||
format!(
|
||
"Failed to remove old standalone script: {}",
|
||
standalone_path.display()
|
||
)
|
||
})?;
|
||
}
|
||
|
||
// Move adjacent meta file into directory if it exists
|
||
let meta_path = containing_dir.join(format!("{}.meta.json5", dir_name));
|
||
if meta_path.exists() {
|
||
let init_meta_path = new_dir.join("init.meta.json5");
|
||
self.suppress_path(&meta_path);
|
||
self.suppress_path(&init_meta_path);
|
||
fs::rename(&meta_path, &init_meta_path).with_context(|| {
|
||
format!(
|
||
"Failed to move meta file {} to {}",
|
||
meta_path.display(),
|
||
init_meta_path.display()
|
||
)
|
||
})?;
|
||
log::info!(
|
||
"Syncback: Moved {} to {}",
|
||
meta_path.display(),
|
||
init_meta_path.display()
|
||
);
|
||
}
|
||
|
||
log::info!(
|
||
"Syncback: Converted standalone {} to directory format at {}",
|
||
class_name,
|
||
new_dir.display()
|
||
);
|
||
|
||
Ok(new_dir)
|
||
}
|
||
|
||
/// Converts a standalone non-script file (e.g., `MyPart.model.json5`, `MyValue.txt`)
|
||
/// into directory format (e.g., `MyPart/init.meta.json5`). This is needed when a
|
||
/// child is being added to any non-script instance that is currently a standalone file.
|
||
///
|
||
/// Returns the path to the new directory.
|
||
fn convert_standalone_instance_to_directory(
|
||
&self,
|
||
standalone_path: &std::path::Path,
|
||
instance_name: &str,
|
||
class_name: &str,
|
||
containing_dir: &std::path::Path,
|
||
) -> anyhow::Result<std::path::PathBuf> {
|
||
use anyhow::Context;
|
||
|
||
let dir_name = dir_name_from_instance_path(standalone_path);
|
||
|
||
let new_dir = containing_dir.join(dir_name);
|
||
self.suppress_path(&new_dir);
|
||
fs::create_dir_all(&new_dir).with_context(|| {
|
||
format!(
|
||
"Failed to create directory for instance conversion: {}",
|
||
new_dir.display()
|
||
)
|
||
})?;
|
||
|
||
// Determine the init file based on the standalone file type.
|
||
// The content of the standalone file becomes the init file inside the directory.
|
||
let file_ext = standalone_path
|
||
.extension()
|
||
.and_then(|e| e.to_str())
|
||
.unwrap_or("");
|
||
|
||
let file_name = standalone_path
|
||
.file_name()
|
||
.and_then(|f| f.to_str())
|
||
.unwrap_or("");
|
||
|
||
if file_name.ends_with(".model.json5") || file_name.ends_with(".model.json") {
|
||
// .model.json5 → init.meta.json5 with className and properties.
|
||
// IMPORTANT: .model.json5 supports a "children" field for inline child
|
||
// instances, but init.meta.json5 (DirectoryMetadata) does not. We must
|
||
// parse the model and extract only the compatible fields, warning if
|
||
// inline children would be lost.
|
||
if standalone_path.exists() {
|
||
let raw = fs::read(standalone_path).unwrap_or_default();
|
||
let init_meta_path = new_dir.join("init.meta.json5");
|
||
|
||
// Parse the model JSON5 to extract only meta-compatible fields.
|
||
// Use json5::from_str (not serde_json) because .model.json5 files
|
||
// can contain JSON5 features (comments, trailing commas, unquoted keys).
|
||
let raw_str = String::from_utf8_lossy(&raw);
|
||
let meta_content = if let Ok(model) = json5::from_str::<serde_json::Value>(&raw_str)
|
||
{
|
||
// Warn about inline children that will be lost
|
||
if let Some(children) = model.get("children").or(model.get("Children")) {
|
||
if children.is_array() && children.as_array().is_some_and(|a| !a.is_empty())
|
||
{
|
||
log::warn!(
|
||
"Syncback: .model.json5 at {} contains inline children \
|
||
that cannot be represented in init.meta.json5 — \
|
||
these children will be lost during directory conversion",
|
||
standalone_path.display()
|
||
);
|
||
}
|
||
}
|
||
|
||
// Build init.meta.json5 with only the supported fields
|
||
let mut meta = serde_json::Map::new();
|
||
if let Some(cn) = model.get("className").or(model.get("ClassName")).cloned() {
|
||
meta.insert("className".to_string(), cn);
|
||
}
|
||
if let Some(props) =
|
||
model.get("properties").or(model.get("Properties")).cloned()
|
||
{
|
||
if props.is_object() && props.as_object().is_some_and(|o| !o.is_empty()) {
|
||
meta.insert("properties".to_string(), props);
|
||
}
|
||
}
|
||
if let Some(attrs) =
|
||
model.get("attributes").or(model.get("Attributes")).cloned()
|
||
{
|
||
if attrs.is_object() && attrs.as_object().is_some_and(|o| !o.is_empty()) {
|
||
meta.insert("attributes".to_string(), attrs);
|
||
}
|
||
}
|
||
if let Some(ignore) = model.get("ignoreUnknownInstances").cloned() {
|
||
meta.insert("ignoreUnknownInstances".to_string(), ignore);
|
||
}
|
||
if let Some(id) = model.get("id").cloned() {
|
||
meta.insert("id".to_string(), id);
|
||
}
|
||
|
||
crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(meta))
|
||
.unwrap_or(raw.clone())
|
||
} else {
|
||
// Parse failed — copy raw as fallback (best-effort)
|
||
log::warn!(
|
||
"Syncback: Could not parse {} as JSON — \
|
||
copying raw content to init.meta.json5",
|
||
standalone_path.display()
|
||
);
|
||
raw
|
||
};
|
||
|
||
self.suppress_path(&init_meta_path);
|
||
fs::write(&init_meta_path, &meta_content)
|
||
.with_context(|| format!("Failed to write {}", init_meta_path.display()))?;
|
||
}
|
||
} else if file_ext == "txt" {
|
||
// StringValue .txt → init.meta.json5 with className and Value property
|
||
let value = if standalone_path.exists() {
|
||
fs::read_to_string(standalone_path).unwrap_or_default()
|
||
} else {
|
||
String::new()
|
||
};
|
||
let meta = serde_json::json!({
|
||
"className": class_name,
|
||
"properties": {
|
||
"Value": value
|
||
}
|
||
});
|
||
let init_meta_path = new_dir.join("init.meta.json5");
|
||
let content = crate::json::to_vec_pretty_sorted(&meta)
|
||
.context("Failed to serialize init.meta.json5")?;
|
||
self.suppress_path(&init_meta_path);
|
||
fs::write(&init_meta_path, &content)
|
||
.with_context(|| format!("Failed to write {}", init_meta_path.display()))?;
|
||
} else if file_ext == "csv" {
|
||
// LocalizationTable .csv → init.csv
|
||
if standalone_path.exists() {
|
||
let content = fs::read(standalone_path).unwrap_or_default();
|
||
let init_csv_path = new_dir.join("init.csv");
|
||
self.suppress_path(&init_csv_path);
|
||
fs::write(&init_csv_path, &content)
|
||
.with_context(|| format!("Failed to write {}", init_csv_path.display()))?;
|
||
}
|
||
} else {
|
||
// Generic fallback: create init.meta.json5 with className
|
||
let meta = serde_json::json!({
|
||
"className": class_name
|
||
});
|
||
let init_meta_path = new_dir.join("init.meta.json5");
|
||
let content = crate::json::to_vec_pretty_sorted(&meta)
|
||
.context("Failed to serialize init.meta.json5")?;
|
||
self.suppress_path(&init_meta_path);
|
||
fs::write(&init_meta_path, &content)
|
||
.with_context(|| format!("Failed to write {}", init_meta_path.display()))?;
|
||
}
|
||
|
||
// Remove the old standalone file
|
||
if standalone_path.exists() {
|
||
self.suppress_path(standalone_path);
|
||
fs::remove_file(standalone_path).with_context(|| {
|
||
format!(
|
||
"Failed to remove old standalone file: {}",
|
||
standalone_path.display()
|
||
)
|
||
})?;
|
||
}
|
||
|
||
log::info!(
|
||
"Syncback: Converted standalone {} '{}' to directory format at {}",
|
||
class_name,
|
||
instance_name,
|
||
new_dir.display()
|
||
);
|
||
|
||
Ok(new_dir)
|
||
}
|
||
|
||
/// Syncback a removed instance by deleting its file(s) from the filesystem.
|
||
/// This is called when the user selects "pull" for an instance that exists in Rojo
|
||
/// but has been deleted in Studio.
|
||
#[allow(dead_code)]
|
||
fn syncback_removed_instance(
|
||
&self,
|
||
removed_id: Ref,
|
||
tree: &crate::snapshot::RojoTree,
|
||
) -> anyhow::Result<()> {
|
||
use anyhow::Context;
|
||
|
||
// Find the instance in the tree
|
||
let instance = tree
|
||
.get_instance(removed_id)
|
||
.context("Instance not found in Rojo tree")?;
|
||
|
||
let instance_name = instance.name();
|
||
|
||
// Get the instigating source and handle each variant appropriately
|
||
let instigating_source = instance
|
||
.metadata()
|
||
.instigating_source
|
||
.as_ref()
|
||
.context("Instance has no filesystem path (not synced from filesystem)")?;
|
||
|
||
// Only delete instances that originate from filesystem paths, not from project nodes
|
||
let instance_path = match instigating_source {
|
||
InstigatingSource::Path(path) => path,
|
||
InstigatingSource::ProjectNode { name, .. } => {
|
||
// Instances defined in project files cannot be removed via syncback
|
||
// because that would require modifying the project file structure
|
||
log::warn!(
|
||
"Syncback: Cannot remove instance '{}' (id: {:?}) - it is defined in a project file",
|
||
name,
|
||
removed_id
|
||
);
|
||
anyhow::bail!(
|
||
"Cannot remove instance '{}' because it is defined in a project file. \
|
||
To remove this instance, edit the project file directly.",
|
||
name
|
||
);
|
||
}
|
||
};
|
||
|
||
// Check if path exists - it may have already been deleted as part of a parent removal
|
||
// (e.g., if both a directory and its children are marked for removal, the directory
|
||
// deletion will recursively delete all children, so we just skip them)
|
||
if !instance_path.exists() {
|
||
log::trace!(
|
||
"Syncback: Path already removed (likely parent was deleted): {}",
|
||
instance_path.display()
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
// Delete the file or directory
|
||
if instance_path.is_dir() {
|
||
self.suppress_path(instance_path);
|
||
fs::remove_dir_all(instance_path).with_context(|| {
|
||
format!("Failed to remove directory: {}", instance_path.display())
|
||
})?;
|
||
log::info!("Syncback: Removed directory at {}", instance_path.display());
|
||
} else if instance_path.is_file() {
|
||
self.suppress_path(instance_path);
|
||
fs::remove_file(instance_path)
|
||
.with_context(|| format!("Failed to remove file: {}", instance_path.display()))?;
|
||
log::info!("Syncback: Removed file at {}", instance_path.display());
|
||
|
||
// Also remove adjacent meta file if it exists
|
||
// The meta file is named after the instance name, not the file name
|
||
// e.g., for "MyScript.server.luau", the meta file is "MyScript.meta.json5"
|
||
if let Some(parent_dir) = instance_path.parent() {
|
||
let meta_path = parent_dir.join(format!("{}.meta.json5", instance_name));
|
||
if meta_path.exists() {
|
||
if let Err(err) = fs::remove_file(&meta_path) {
|
||
log::warn!(
|
||
"Failed to remove adjacent meta file {}: {}",
|
||
meta_path.display(),
|
||
err
|
||
);
|
||
} else {
|
||
log::info!(
|
||
"Syncback: Removed adjacent meta file at {}",
|
||
meta_path.display()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Check for duplicate names among children and return the set of unique children
|
||
/// that should be synced. Records skipped instances via stats tracker.
|
||
fn filter_duplicate_children<'a>(
|
||
&self,
|
||
children: &'a [crate::web::interface::AddedInstance],
|
||
parent_path: &str,
|
||
stats: &crate::syncback::SyncbackStats,
|
||
) -> Vec<&'a crate::web::interface::AddedInstance> {
|
||
use std::collections::HashMap;
|
||
|
||
// Count occurrences of each name
|
||
let mut name_counts: HashMap<&str, usize> = HashMap::new();
|
||
for child in children {
|
||
*name_counts.entry(&child.name).or_insert(0) += 1;
|
||
}
|
||
|
||
// Find duplicates
|
||
let duplicates: std::collections::HashSet<&str> = name_counts
|
||
.iter()
|
||
.filter(|(_, &count)| count > 1)
|
||
.map(|(&name, _)| name)
|
||
.collect();
|
||
|
||
// Record skipped duplicates in stats
|
||
if !duplicates.is_empty() {
|
||
let mut skipped_count = 0;
|
||
for child in children {
|
||
if duplicates.contains(child.name.as_str()) {
|
||
skipped_count += 1;
|
||
}
|
||
}
|
||
let duplicate_list: Vec<&str> = duplicates.iter().copied().collect();
|
||
stats.record_duplicate_names_batch(parent_path, &duplicate_list, skipped_count);
|
||
}
|
||
|
||
// Return only non-duplicate children
|
||
children
|
||
.iter()
|
||
.filter(|child| !duplicates.contains(child.name.as_str()))
|
||
.collect()
|
||
}
|
||
|
||
/// Detects what file format currently exists on disk for a given instance.
|
||
/// This is used to preserve the existing format during partial updates.
|
||
///
|
||
/// When the plugin sends a script update, it might not include children info.
|
||
/// We check the filesystem to see if this is already a directory (with init file)
|
||
/// or a standalone file, and preserve that format.
|
||
fn detect_existing_script_format(
|
||
parent_dir: &std::path::Path,
|
||
name: &str,
|
||
class_name: &str,
|
||
) -> ExistingFileFormat {
|
||
let dir_path = parent_dir.join(name);
|
||
|
||
// Check for directory with init file first (takes precedence)
|
||
if dir_path.is_dir() {
|
||
// Check for any init file based on class type
|
||
let init_files = match class_name {
|
||
"ModuleScript" => vec!["init.luau", "init.lua"],
|
||
"Script" => vec![
|
||
"init.server.luau",
|
||
"init.server.lua",
|
||
"init.client.luau",
|
||
"init.client.lua",
|
||
],
|
||
"LocalScript" => vec![
|
||
// Modern: init.local.luau produces LocalScript
|
||
// Legacy: init.client.lua produces LocalScript (without 'u')
|
||
// Note: init.client.luau produces Script with Client RunContext, NOT LocalScript!
|
||
"init.local.luau",
|
||
"init.local.lua",
|
||
"init.client.lua", // Legacy only (no .luau!)
|
||
],
|
||
// For non-scripts, check for init.model.json5 or similar
|
||
_ => vec!["init.model.json5", "init.model.json", "init.meta.json5"],
|
||
};
|
||
|
||
for init_file in init_files {
|
||
if dir_path.join(init_file).exists() {
|
||
return ExistingFileFormat::Directory(dir_path.clone());
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check for standalone file
|
||
let standalone_patterns = match class_name {
|
||
"ModuleScript" => vec![format!("{}.luau", name), format!("{}.lua", name)],
|
||
"Script" => vec![
|
||
format!("{}.server.luau", name),
|
||
format!("{}.server.lua", name),
|
||
format!("{}.client.luau", name),
|
||
format!("{}.client.lua", name),
|
||
],
|
||
"LocalScript" => vec![
|
||
// Modern: .local.luau produces LocalScript
|
||
// Legacy: .client.lua produces LocalScript (without 'u')
|
||
// Note: .client.luau produces Script with Client RunContext, NOT LocalScript!
|
||
format!("{}.local.luau", name),
|
||
format!("{}.local.lua", name),
|
||
format!("{}.client.lua", name), // Legacy only (no .luau!)
|
||
],
|
||
// For non-scripts, check for model files
|
||
_ => vec![
|
||
format!("{}.model.json5", name),
|
||
format!("{}.model.json", name),
|
||
],
|
||
};
|
||
|
||
for pattern in standalone_patterns {
|
||
let full_path = parent_dir.join(&pattern);
|
||
if full_path.exists() {
|
||
return ExistingFileFormat::Standalone(full_path);
|
||
}
|
||
}
|
||
|
||
ExistingFileFormat::None
|
||
}
|
||
|
||
/// Recursively syncback an instance and its children to the filesystem.
|
||
/// This is the internal implementation that handles the actual file creation.
|
||
#[allow(dead_code)]
|
||
fn syncback_instance_to_path(
|
||
&self,
|
||
added: &crate::web::interface::AddedInstance,
|
||
parent_dir: &std::path::Path,
|
||
) -> anyhow::Result<()> {
|
||
// Use a default stats tracker for backwards compatibility
|
||
let stats = crate::syncback::SyncbackStats::new();
|
||
self.syncback_instance_to_path_with_stats(added, parent_dir, &stats)
|
||
}
|
||
|
||
/// Recursively syncback an instance and its children to the filesystem.
|
||
/// This is the internal implementation that handles the actual file creation.
|
||
/// Uses the provided stats tracker for recording issues.
|
||
fn syncback_instance_to_path_with_stats(
|
||
&self,
|
||
added: &crate::web::interface::AddedInstance,
|
||
parent_dir: &std::path::Path,
|
||
stats: &crate::syncback::SyncbackStats,
|
||
) -> anyhow::Result<()> {
|
||
use crate::syncback::validate_file_name;
|
||
use anyhow::Context;
|
||
|
||
// Validate the instance name for filesystem safety
|
||
validate_file_name(&added.name).with_context(|| {
|
||
format!(
|
||
"Instance name '{}' is not valid for the filesystem",
|
||
added.name
|
||
)
|
||
})?;
|
||
|
||
// Build path string for stats
|
||
let inst_path = format!("{}/{}", parent_dir.display(), added.name);
|
||
|
||
// Filter out children with duplicate names (cannot reliably sync)
|
||
let unique_children = self.filter_duplicate_children(&added.children, &inst_path, stats);
|
||
let has_children = !unique_children.is_empty();
|
||
|
||
// Determine the appropriate middleware/file format based on class name.
|
||
// This matches the logic in src/syncback/mod.rs::get_best_middleware.
|
||
//
|
||
// Format transitions (matching `rojo syncback` behavior):
|
||
// - Standalone + children added → convert to directory (clean up old standalone)
|
||
// - Standalone + no children → keep standalone
|
||
// - Directory + children or not → PRESERVE directory (plugin may omit children)
|
||
// - None + has_children → directory (new instance)
|
||
// - None + no children → standalone (new instance)
|
||
let existing_format =
|
||
Self::detect_existing_script_format(parent_dir, &added.name, &added.class_name);
|
||
|
||
match added.class_name.as_str() {
|
||
// Script types: .luau files, or directories with init files if has children.
|
||
// Format is driven by has_children (matching `rojo syncback` behavior).
|
||
// If the existing format doesn't match, we convert (standalone↔directory).
|
||
"ModuleScript" => {
|
||
let source = self.get_source_property(added);
|
||
|
||
// Standalone→directory when children are added.
|
||
// Directory is preserved when no children (plugin may omit children in partial updates).
|
||
// New instances use has_children to decide.
|
||
let use_directory = match &existing_format {
|
||
ExistingFileFormat::Directory(_) => true, // preserve directory
|
||
ExistingFileFormat::Standalone(_) => has_children, // convert only if children added
|
||
ExistingFileFormat::None => has_children,
|
||
};
|
||
|
||
if use_directory {
|
||
// Transition standalone → directory if needed
|
||
if let ExistingFileFormat::Standalone(ref old_path) = existing_format {
|
||
log::info!(
|
||
"Syncback: Converting ModuleScript {} from standalone to directory (children added)",
|
||
added.name
|
||
);
|
||
if old_path.exists() {
|
||
self.suppress_path(old_path);
|
||
let _ = fs::remove_file(old_path);
|
||
}
|
||
let meta_path = parent_dir.join(format!("{}.meta.json5", added.name));
|
||
if meta_path.exists() {
|
||
self.suppress_path(&meta_path);
|
||
let _ = fs::remove_file(&meta_path);
|
||
}
|
||
}
|
||
|
||
let dir_path = parent_dir.join(&added.name);
|
||
self.suppress_path(&dir_path);
|
||
fs::create_dir_all(&dir_path).with_context(|| {
|
||
format!("Failed to create directory: {}", dir_path.display())
|
||
})?;
|
||
let init_path = dir_path.join("init.luau");
|
||
self.suppress_path(&init_path);
|
||
fs::write(&init_path, source.as_bytes()).with_context(|| {
|
||
format!("Failed to write file: {}", init_path.display())
|
||
})?;
|
||
self.write_script_meta_json_if_needed(&dir_path, added)?;
|
||
log::info!("Syncback: Updated ModuleScript at {}", init_path.display());
|
||
for child in &unique_children {
|
||
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
|
||
}
|
||
} else {
|
||
let file_path = parent_dir.join(format!("{}.luau", added.name));
|
||
self.suppress_path(&file_path);
|
||
fs::write(&file_path, source.as_bytes()).with_context(|| {
|
||
format!("Failed to write file: {}", file_path.display())
|
||
})?;
|
||
self.write_adjacent_script_meta_if_needed(parent_dir, &added.name, added)?;
|
||
log::info!("Syncback: Updated ModuleScript at {}", file_path.display());
|
||
}
|
||
}
|
||
"Script" => {
|
||
let source = self.get_source_property(added);
|
||
let script_suffix = self.get_script_suffix_for_run_context(added);
|
||
|
||
let use_directory = match &existing_format {
|
||
ExistingFileFormat::Directory(_) => true,
|
||
ExistingFileFormat::Standalone(_) => has_children,
|
||
ExistingFileFormat::None => has_children,
|
||
};
|
||
|
||
if use_directory {
|
||
if let ExistingFileFormat::Standalone(ref old_path) = existing_format {
|
||
log::info!(
|
||
"Syncback: Converting Script {} from standalone to directory (children added)",
|
||
added.name
|
||
);
|
||
if old_path.exists() {
|
||
self.suppress_path(old_path);
|
||
let _ = fs::remove_file(old_path);
|
||
}
|
||
let meta_path = parent_dir.join(format!("{}.meta.json5", added.name));
|
||
if meta_path.exists() {
|
||
self.suppress_path(&meta_path);
|
||
let _ = fs::remove_file(&meta_path);
|
||
}
|
||
}
|
||
|
||
let dir_path = parent_dir.join(&added.name);
|
||
self.suppress_path(&dir_path);
|
||
fs::create_dir_all(&dir_path).with_context(|| {
|
||
format!("Failed to create directory: {}", dir_path.display())
|
||
})?;
|
||
let init_path = dir_path.join(format!("init.{}.luau", script_suffix));
|
||
self.suppress_path(&init_path);
|
||
fs::write(&init_path, source.as_bytes()).with_context(|| {
|
||
format!("Failed to write file: {}", init_path.display())
|
||
})?;
|
||
self.write_script_meta_json_if_needed(&dir_path, added)?;
|
||
log::info!("Syncback: Updated Script at {}", init_path.display());
|
||
for child in &unique_children {
|
||
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
|
||
}
|
||
} else {
|
||
let file_path =
|
||
parent_dir.join(format!("{}.{}.luau", added.name, script_suffix));
|
||
self.suppress_path(&file_path);
|
||
fs::write(&file_path, source.as_bytes()).with_context(|| {
|
||
format!("Failed to write file: {}", file_path.display())
|
||
})?;
|
||
self.write_adjacent_script_meta_if_needed(parent_dir, &added.name, added)?;
|
||
log::info!("Syncback: Updated Script at {}", file_path.display());
|
||
}
|
||
}
|
||
"LocalScript" => {
|
||
let source = self.get_source_property(added);
|
||
|
||
let use_directory = match &existing_format {
|
||
ExistingFileFormat::Directory(_) => true,
|
||
ExistingFileFormat::Standalone(_) => has_children,
|
||
ExistingFileFormat::None => has_children,
|
||
};
|
||
|
||
if use_directory {
|
||
if let ExistingFileFormat::Standalone(ref old_path) = existing_format {
|
||
log::info!(
|
||
"Syncback: Converting LocalScript {} from standalone to directory (children added)",
|
||
added.name
|
||
);
|
||
if old_path.exists() {
|
||
self.suppress_path(old_path);
|
||
let _ = fs::remove_file(old_path);
|
||
}
|
||
let meta_path = parent_dir.join(format!("{}.meta.json5", added.name));
|
||
if meta_path.exists() {
|
||
self.suppress_path(&meta_path);
|
||
let _ = fs::remove_file(&meta_path);
|
||
}
|
||
}
|
||
|
||
let dir_path = parent_dir.join(&added.name);
|
||
self.suppress_path(&dir_path);
|
||
fs::create_dir_all(&dir_path).with_context(|| {
|
||
format!("Failed to create directory: {}", dir_path.display())
|
||
})?;
|
||
let init_path = dir_path.join("init.local.luau");
|
||
self.suppress_path(&init_path);
|
||
fs::write(&init_path, source.as_bytes()).with_context(|| {
|
||
format!("Failed to write file: {}", init_path.display())
|
||
})?;
|
||
self.write_script_meta_json_if_needed(&dir_path, added)?;
|
||
log::info!("Syncback: Updated LocalScript at {}", init_path.display());
|
||
for child in &unique_children {
|
||
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
|
||
}
|
||
} else {
|
||
let file_path = parent_dir.join(format!("{}.local.luau", added.name));
|
||
self.suppress_path(&file_path);
|
||
fs::write(&file_path, source.as_bytes()).with_context(|| {
|
||
format!("Failed to write file: {}", file_path.display())
|
||
})?;
|
||
self.write_adjacent_script_meta_if_needed(parent_dir, &added.name, added)?;
|
||
log::info!("Syncback: Updated LocalScript at {}", file_path.display());
|
||
}
|
||
}
|
||
|
||
// Directory-native classes: always become directories
|
||
"Folder" | "Configuration" | "Tool" | "ScreenGui" | "SurfaceGui" | "BillboardGui"
|
||
| "AdGui" => {
|
||
let dir_path = parent_dir.join(&added.name);
|
||
self.suppress_path(&dir_path);
|
||
fs::create_dir_all(&dir_path).with_context(|| {
|
||
format!("Failed to create directory: {}", dir_path.display())
|
||
})?;
|
||
|
||
// Write init.meta.json5 if has ANY properties (Attributes, Tags, etc.)
|
||
let has_metadata = if added.properties.is_empty() {
|
||
false
|
||
} else {
|
||
self.write_directory_meta_json(&dir_path, added)?;
|
||
true
|
||
};
|
||
|
||
// Add .gitkeep for empty directories with no metadata (matches dedicated syncback)
|
||
// Uses !has_children which accounts for filtered duplicate children
|
||
if !has_children && !has_metadata {
|
||
let gitkeep = dir_path.join(".gitkeep");
|
||
self.suppress_path(&gitkeep);
|
||
fs::write(gitkeep, b"").with_context(|| "Failed to write .gitkeep")?;
|
||
}
|
||
|
||
log::info!(
|
||
"Syncback: Created {} directory at {}",
|
||
added.class_name,
|
||
dir_path.display()
|
||
);
|
||
|
||
// Recursively process children
|
||
for child in &unique_children {
|
||
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
|
||
}
|
||
}
|
||
|
||
// StringValue: .txt file if no children, directory with init.meta.json5 if has children
|
||
"StringValue" => {
|
||
if has_children {
|
||
// Must become directory - store StringValue data in init.meta.json5
|
||
let dir_path = parent_dir.join(&added.name);
|
||
self.suppress_path(&dir_path);
|
||
fs::create_dir_all(&dir_path).with_context(|| {
|
||
format!("Failed to create directory: {}", dir_path.display())
|
||
})?;
|
||
self.write_init_meta_json(&dir_path, added)?;
|
||
log::info!(
|
||
"Syncback: Created StringValue directory at {}",
|
||
dir_path.display()
|
||
);
|
||
for child in &unique_children {
|
||
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
|
||
}
|
||
} else {
|
||
let value = added
|
||
.properties
|
||
.get("Value")
|
||
.and_then(|v| match v {
|
||
Variant::String(s) => Some(s.clone()),
|
||
_ => None,
|
||
})
|
||
.unwrap_or_default();
|
||
let file_path = parent_dir.join(format!("{}.txt", added.name));
|
||
self.suppress_path(&file_path);
|
||
fs::write(&file_path, value.as_bytes()).with_context(|| {
|
||
format!("Failed to write file: {}", file_path.display())
|
||
})?;
|
||
log::info!("Syncback: Created StringValue at {}", file_path.display());
|
||
}
|
||
}
|
||
|
||
// LocalizationTable: .csv file if no children, directory with init.csv if has children
|
||
"LocalizationTable" => {
|
||
// TODO: Full CSV support would serialize the Contents property
|
||
let content = "Key,Source,Context,Example,en\n";
|
||
|
||
if has_children {
|
||
// Must become directory with init.csv
|
||
let dir_path = parent_dir.join(&added.name);
|
||
self.suppress_path(&dir_path);
|
||
fs::create_dir_all(&dir_path).with_context(|| {
|
||
format!("Failed to create directory: {}", dir_path.display())
|
||
})?;
|
||
let init_path = dir_path.join("init.csv");
|
||
self.suppress_path(&init_path);
|
||
fs::write(&init_path, content.as_bytes()).with_context(|| {
|
||
format!("Failed to write file: {}", init_path.display())
|
||
})?;
|
||
log::info!(
|
||
"Syncback: Created LocalizationTable directory at {}",
|
||
dir_path.display()
|
||
);
|
||
for child in &unique_children {
|
||
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
|
||
}
|
||
} else {
|
||
let file_path = parent_dir.join(format!("{}.csv", added.name));
|
||
self.suppress_path(&file_path);
|
||
fs::write(&file_path, content.as_bytes()).with_context(|| {
|
||
format!("Failed to write file: {}", file_path.display())
|
||
})?;
|
||
log::info!(
|
||
"Syncback: Created LocalizationTable at {}",
|
||
file_path.display()
|
||
);
|
||
}
|
||
}
|
||
|
||
// All other classes -> .model.json5 or directory if has children.
|
||
_ => {
|
||
let use_directory = match &existing_format {
|
||
ExistingFileFormat::Directory(_) => true, // preserve directory
|
||
ExistingFileFormat::Standalone(_) => has_children, // convert only if children added
|
||
ExistingFileFormat::None => has_children,
|
||
};
|
||
|
||
if use_directory {
|
||
// Transition standalone → directory if needed
|
||
if let ExistingFileFormat::Standalone(ref old_path) = existing_format {
|
||
log::info!(
|
||
"Syncback: Converting {} {} from standalone to directory (children added)",
|
||
added.class_name,
|
||
added.name
|
||
);
|
||
if old_path.exists() {
|
||
self.suppress_path(old_path);
|
||
let _ = fs::remove_file(old_path);
|
||
}
|
||
}
|
||
|
||
let dir_path = parent_dir.join(&added.name);
|
||
self.suppress_path(&dir_path);
|
||
fs::create_dir_all(&dir_path).with_context(|| {
|
||
format!("Failed to create directory: {}", dir_path.display())
|
||
})?;
|
||
|
||
// Write init.meta.json5 with class and properties
|
||
self.write_init_meta_json(&dir_path, added)?;
|
||
|
||
log::info!(
|
||
"Syncback: Updated {} at {}/init.meta.json5",
|
||
added.class_name,
|
||
dir_path.display()
|
||
);
|
||
|
||
// Recursively process children
|
||
for child in &unique_children {
|
||
self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?;
|
||
}
|
||
} else {
|
||
let content = self.serialize_instance_to_model_json(added)?;
|
||
// Use the detected file path if available (preserves .model.json
|
||
// vs .model.json5), otherwise default to .model.json5
|
||
let file_path = match &existing_format {
|
||
ExistingFileFormat::Standalone(p) => p.clone(),
|
||
_ => parent_dir.join(format!("{}.model.json5", added.name)),
|
||
};
|
||
self.suppress_path(&file_path);
|
||
fs::write(&file_path, &content).with_context(|| {
|
||
format!("Failed to write file: {}", file_path.display())
|
||
})?;
|
||
log::info!(
|
||
"Syncback: Updated {} at {}",
|
||
added.class_name,
|
||
file_path.display()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Write init.meta.json5 for script directories if they have non-Source properties
|
||
/// This matches the behavior of the dedicated syncback system.
|
||
fn write_script_meta_json_if_needed(
|
||
&self,
|
||
dir_path: &std::path::Path,
|
||
added: &crate::web::interface::AddedInstance,
|
||
) -> anyhow::Result<()> {
|
||
use anyhow::Context;
|
||
|
||
// Check if there are any properties besides Source that need to be saved
|
||
// Use IndexMap for consistent ordering (like dedicated syncback)
|
||
let (properties, attributes) =
|
||
self.filter_properties_for_meta(&added.class_name, &added.properties, Some("Source"));
|
||
|
||
if properties.is_empty() && attributes.is_empty() {
|
||
return Ok(());
|
||
}
|
||
|
||
let meta = self.build_meta_object(None, properties, attributes);
|
||
let meta_path = dir_path.join("init.meta.json5");
|
||
let content = crate::json::to_vec_pretty_sorted(&meta)
|
||
.context("Failed to serialize init.meta.json5")?;
|
||
self.suppress_path(&meta_path);
|
||
fs::write(&meta_path, &content)
|
||
.with_context(|| format!("Failed to write meta file: {}", meta_path.display()))?;
|
||
log::info!(
|
||
"Syncback: Created init.meta.json5 for script at {}",
|
||
meta_path.display()
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Write init.meta.json5 for directory-native classes (Folder, Configuration, etc.)
|
||
/// Includes all properties (Attributes, Tags, etc.)
|
||
/// This matches the behavior of the dedicated syncback system.
|
||
fn write_directory_meta_json(
|
||
&self,
|
||
dir_path: &std::path::Path,
|
||
added: &crate::web::interface::AddedInstance,
|
||
) -> anyhow::Result<()> {
|
||
use anyhow::Context;
|
||
|
||
let (properties, attributes) =
|
||
self.filter_properties_for_meta(&added.class_name, &added.properties, None);
|
||
|
||
if properties.is_empty() && attributes.is_empty() {
|
||
return Ok(());
|
||
}
|
||
|
||
let meta = self.build_meta_object(None, properties, attributes);
|
||
let meta_path = dir_path.join("init.meta.json5");
|
||
let content = crate::json::to_vec_pretty_sorted(&meta)
|
||
.context("Failed to serialize init.meta.json5")?;
|
||
self.suppress_path(&meta_path);
|
||
fs::write(&meta_path, &content)
|
||
.with_context(|| format!("Failed to write meta file: {}", meta_path.display()))?;
|
||
log::info!(
|
||
"Syncback: Created init.meta.json5 at {}",
|
||
meta_path.display()
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Write an init.meta.json5 file for non-standard instances that have children
|
||
/// This matches the behavior of the dedicated syncback system.
|
||
fn write_init_meta_json(
|
||
&self,
|
||
dir_path: &std::path::Path,
|
||
added: &crate::web::interface::AddedInstance,
|
||
) -> anyhow::Result<()> {
|
||
use anyhow::Context;
|
||
|
||
let (properties, attributes) =
|
||
self.filter_properties_for_meta(&added.class_name, &added.properties, None);
|
||
|
||
// For non-Folder classes, we need to include className
|
||
let meta = self.build_meta_object(Some(&added.class_name), properties, attributes);
|
||
let meta_path = dir_path.join("init.meta.json5");
|
||
let content = crate::json::to_vec_pretty_sorted(&meta)
|
||
.context("Failed to serialize init.meta.json5")?;
|
||
self.suppress_path(&meta_path);
|
||
fs::write(&meta_path, &content)
|
||
.with_context(|| format!("Failed to write meta file: {}", meta_path.display()))?;
|
||
log::info!(
|
||
"Syncback: Created init.meta.json5 for {} at {}",
|
||
added.class_name,
|
||
meta_path.display()
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Filter properties for meta file serialization, matching dedicated syncback behavior.
|
||
/// Returns (properties, attributes) as separate IndexMaps.
|
||
///
|
||
/// Filters out:
|
||
/// - Ref and UniqueId properties (don't serialize to JSON)
|
||
/// - The skip_property if specified (e.g., "Source" for scripts)
|
||
/// - Properties that don't serialize (DoesNotSerialize in reflection database)
|
||
/// - Properties that match their default value
|
||
/// - Internal RBX* prefixed attributes
|
||
fn filter_properties_for_meta(
|
||
&self,
|
||
class_name: &str,
|
||
props: &HashMap<String, Variant>,
|
||
skip_property: Option<&str>,
|
||
) -> (
|
||
indexmap::IndexMap<String, serde_json::Value>,
|
||
indexmap::IndexMap<String, serde_json::Value>,
|
||
) {
|
||
use crate::syncback::should_property_serialize;
|
||
use crate::variant_eq::variant_eq;
|
||
use indexmap::IndexMap;
|
||
|
||
let mut properties: IndexMap<String, serde_json::Value> = IndexMap::new();
|
||
let mut attributes: IndexMap<String, serde_json::Value> = IndexMap::new();
|
||
|
||
// Get reflection database for default value comparison
|
||
let class_data = rbx_reflection_database::get()
|
||
.ok()
|
||
.and_then(|db| db.classes.get(class_name));
|
||
|
||
for (name, value) in props {
|
||
// Skip the specified property (e.g., Source for scripts)
|
||
if let Some(skip) = skip_property {
|
||
if name == skip {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Skip RunContext - it's encoded in the file suffix (.server.luau, .client.luau, etc.)
|
||
// and Enum variants can't be properly serialized without type context
|
||
if name == "RunContext" {
|
||
continue;
|
||
}
|
||
|
||
// Skip Ref and UniqueId - they don't serialize to JSON (matches dedicated syncback)
|
||
if matches!(value, Variant::Ref(_) | Variant::UniqueId(_)) {
|
||
continue;
|
||
}
|
||
|
||
// Handle Attributes specially - extract into separate map and filter RBX* prefix
|
||
if let Variant::Attributes(attrs) = value {
|
||
for (attr_name, attr_value) in attrs.iter() {
|
||
// Skip internal Roblox attributes (matches dedicated syncback)
|
||
if attr_name.starts_with("RBX") {
|
||
continue;
|
||
}
|
||
if let Some(json_value) = variant_to_json(attr_value) {
|
||
attributes.insert(attr_name.clone(), json_value);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Check if property should serialize (matches dedicated syncback property_filter.rs)
|
||
if !should_property_serialize(class_name, name) {
|
||
continue;
|
||
}
|
||
|
||
// Skip properties that match their default value (matches dedicated syncback)
|
||
if let Some(data) = class_data {
|
||
if let Some(default) = data.default_properties.get(name.as_str()) {
|
||
if variant_eq(value, default) {
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Convert other properties
|
||
if let Some(json_value) = variant_to_json(value) {
|
||
properties.insert(name.clone(), json_value);
|
||
}
|
||
}
|
||
|
||
(properties, attributes)
|
||
}
|
||
|
||
/// Build a meta object (for init.meta.json5) matching dedicated syncback format.
|
||
fn build_meta_object(
|
||
&self,
|
||
class_name: Option<&str>,
|
||
properties: indexmap::IndexMap<String, serde_json::Value>,
|
||
attributes: indexmap::IndexMap<String, serde_json::Value>,
|
||
) -> serde_json::Value {
|
||
use serde_json::json;
|
||
|
||
let mut obj = serde_json::Map::new();
|
||
|
||
if let Some(cn) = class_name {
|
||
obj.insert("className".to_string(), json!(cn));
|
||
}
|
||
|
||
if !properties.is_empty() {
|
||
obj.insert("properties".to_string(), json!(properties));
|
||
}
|
||
|
||
if !attributes.is_empty() {
|
||
obj.insert("attributes".to_string(), json!(attributes));
|
||
}
|
||
|
||
serde_json::Value::Object(obj)
|
||
}
|
||
|
||
/// Extract the Source property from an added instance, defaulting to empty string.
|
||
fn get_source_property(&self, added: &crate::web::interface::AddedInstance) -> String {
|
||
added
|
||
.properties
|
||
.get("Source")
|
||
.and_then(|v| match v {
|
||
Variant::String(s) => Some(s.clone()),
|
||
_ => None,
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Determine the script file suffix based on RunContext property.
|
||
///
|
||
/// For `Script` class:
|
||
/// - RunContext: Client → "client"
|
||
/// - RunContext: Server → "server"
|
||
/// - RunContext: Legacy → "legacy"
|
||
/// - RunContext: Plugin → "plugin"
|
||
/// - No RunContext → "legacy" (default)
|
||
fn get_script_suffix_for_run_context(
|
||
&self,
|
||
added: &crate::web::interface::AddedInstance,
|
||
) -> &'static str {
|
||
// Get RunContext enum values from reflection database
|
||
let run_context_enums = rbx_reflection_database::get()
|
||
.ok()
|
||
.and_then(|db| db.enums.get("RunContext"))
|
||
.map(|e| &e.items);
|
||
|
||
let run_context_value = added.properties.get("RunContext").and_then(|v| match v {
|
||
Variant::Enum(e) => Some(e.to_u32()),
|
||
_ => None,
|
||
});
|
||
|
||
if let (Some(enums), Some(value)) = (run_context_enums, run_context_value) {
|
||
// Find which RunContext this value corresponds to
|
||
for (name, &enum_value) in enums {
|
||
if enum_value == value {
|
||
return match name.as_ref() {
|
||
"Client" => "client",
|
||
"Server" => "server",
|
||
"Legacy" => "legacy",
|
||
"Plugin" => "plugin",
|
||
_ => "legacy",
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// Default to legacy if no RunContext or unrecognized
|
||
"legacy"
|
||
}
|
||
|
||
/// Write an adjacent meta file for scripts without children.
|
||
///
|
||
/// Creates `{name}.meta.json5` next to the script file if there are
|
||
/// non-Source properties that need to be preserved.
|
||
///
|
||
/// This matches the dedicated syncback behavior in lua.rs::syncback_lua.
|
||
fn write_adjacent_script_meta_if_needed(
|
||
&self,
|
||
parent_dir: &std::path::Path,
|
||
name: &str,
|
||
added: &crate::web::interface::AddedInstance,
|
||
) -> anyhow::Result<()> {
|
||
use anyhow::Context;
|
||
|
||
// Filter properties, skipping Source (it's in the .luau file)
|
||
let (properties, attributes) =
|
||
self.filter_properties_for_meta(&added.class_name, &added.properties, Some("Source"));
|
||
|
||
if properties.is_empty() && attributes.is_empty() {
|
||
return Ok(());
|
||
}
|
||
|
||
// Build meta object (no className needed for scripts - it's determined by file extension)
|
||
let meta = self.build_meta_object(None, properties, attributes);
|
||
let meta_path = parent_dir.join(format!("{}.meta.json5", name));
|
||
let content =
|
||
crate::json::to_vec_pretty_sorted(&meta).context("Failed to serialize meta.json5")?;
|
||
self.suppress_path(&meta_path);
|
||
fs::write(&meta_path, &content)
|
||
.with_context(|| format!("Failed to write meta file: {}", meta_path.display()))?;
|
||
log::info!(
|
||
"Syncback: Created adjacent meta file at {}",
|
||
meta_path.display()
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Serialize an instance to model.json5 format (human-readable, version-control friendly)
|
||
/// This matches the format used by the dedicated syncback system.
|
||
fn serialize_instance_to_model_json(
|
||
&self,
|
||
added: &crate::web::interface::AddedInstance,
|
||
) -> anyhow::Result<Vec<u8>> {
|
||
use anyhow::Context;
|
||
use serde_json::json;
|
||
|
||
// Filter properties matching dedicated syncback behavior
|
||
let (properties, attributes) =
|
||
self.filter_properties_for_meta(&added.class_name, &added.properties, None);
|
||
|
||
// Build the JSON model structure
|
||
// Format: https://rojo.space/docs/v7/sync-details/#json-models
|
||
let mut model = serde_json::Map::new();
|
||
model.insert("className".to_string(), json!(added.class_name));
|
||
|
||
if !properties.is_empty() {
|
||
model.insert("properties".to_string(), json!(properties));
|
||
}
|
||
|
||
if !attributes.is_empty() {
|
||
model.insert("attributes".to_string(), json!(attributes));
|
||
}
|
||
|
||
// Use sorted JSON5 serialization to match dedicated syncback
|
||
crate::json::to_vec_pretty_sorted(&model).context("Failed to serialize model.json5")
|
||
}
|
||
|
||
/// Persist non-Source property changes to the appropriate meta/model file.
|
||
/// Source is handled by the ChangeProcessor; this handles everything else
|
||
/// (Attributes, custom properties, etc.) so they survive a Rojo restart.
|
||
fn syncback_updated_properties(
|
||
&self,
|
||
update: &crate::web::interface::InstanceUpdate,
|
||
tree: &crate::snapshot::RojoTree,
|
||
) -> anyhow::Result<()> {
|
||
use anyhow::Context;
|
||
|
||
// Check if there are any non-Source property changes to persist
|
||
let has_non_source = update
|
||
.changed_properties
|
||
.iter()
|
||
.any(|(key, _)| key != "Source");
|
||
|
||
if !has_non_source {
|
||
return Ok(());
|
||
}
|
||
|
||
// Look up the instance in the tree
|
||
let instance = tree
|
||
.get_instance(update.id)
|
||
.context("Instance not found in tree for property persistence")?;
|
||
|
||
let instigating_source = instance
|
||
.metadata()
|
||
.instigating_source
|
||
.as_ref()
|
||
.context("Instance has no filesystem path")?;
|
||
|
||
// ProjectNode instances are defined in the project file. Writing meta
|
||
// JSON to the project file path would corrupt it. Only filesystem-backed
|
||
// instances (InstigatingSource::Path) can have properties persisted.
|
||
let inst_path = match instigating_source {
|
||
InstigatingSource::Path(p) => p.as_path(),
|
||
InstigatingSource::ProjectNode { name, .. } => {
|
||
log::warn!(
|
||
"Cannot persist non-Source properties for instance '{}' (id: {:?}) — \
|
||
it is defined in a project file. Edit the project file directly.",
|
||
name,
|
||
update.id
|
||
);
|
||
return Ok(());
|
||
}
|
||
};
|
||
|
||
let class_name = instance.class_name();
|
||
let is_script = matches!(
|
||
class_name.as_str(),
|
||
"ModuleScript" | "Script" | "LocalScript"
|
||
);
|
||
|
||
// Collect the non-Source properties as a HashMap<String, Variant>
|
||
// for reuse with filter_properties_for_meta
|
||
let mut props: HashMap<String, Variant> = HashMap::new();
|
||
for (key, value) in &update.changed_properties {
|
||
if key == "Source" {
|
||
continue;
|
||
}
|
||
if let Some(v) = value {
|
||
props.insert(key.to_string(), v.clone());
|
||
}
|
||
}
|
||
|
||
if props.is_empty() {
|
||
return Ok(());
|
||
}
|
||
|
||
let skip_prop = if is_script { Some("Source") } else { None };
|
||
let (properties, attributes) =
|
||
self.filter_properties_for_meta(class_name.as_str(), &props, skip_prop);
|
||
|
||
if properties.is_empty() && attributes.is_empty() {
|
||
return Ok(());
|
||
}
|
||
|
||
// Determine which meta file to write based on file structure
|
||
if inst_path.is_dir() {
|
||
// Directory format: write to init.meta.json5
|
||
let meta_path = inst_path.join("init.meta.json5");
|
||
|
||
// Read existing meta if present, merge with new properties
|
||
let meta = self.merge_or_build_meta(&meta_path, None, properties, attributes)?;
|
||
let content = crate::json::to_vec_pretty_sorted(&meta)
|
||
.context("Failed to serialize init.meta.json5")?;
|
||
self.suppress_path(&meta_path);
|
||
fs::write(&meta_path, &content)
|
||
.with_context(|| format!("Failed to write {}", meta_path.display()))?;
|
||
|
||
log::info!(
|
||
"Syncback: Persisted non-Source properties to {}",
|
||
meta_path.display()
|
||
);
|
||
} else if is_script {
|
||
// Standalone script: write to adjacent Name.meta.json5
|
||
// Derive the base name from the filesystem path (not instance.name())
|
||
// to preserve Windows-invalid character encoding (e.g., %3F for ?).
|
||
// This matches AdjacentMetadata::read_and_apply_all which uses file_stem().
|
||
let parent_dir = inst_path.parent().context("No parent directory")?;
|
||
let file_stem = inst_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||
let base_name = file_stem
|
||
.strip_suffix(".server")
|
||
.or_else(|| file_stem.strip_suffix(".client"))
|
||
.or_else(|| file_stem.strip_suffix(".plugin"))
|
||
.or_else(|| file_stem.strip_suffix(".local"))
|
||
.or_else(|| file_stem.strip_suffix(".legacy"))
|
||
.unwrap_or(file_stem);
|
||
let meta_path = parent_dir.join(format!("{}.meta.json5", base_name));
|
||
|
||
let meta = self.merge_or_build_meta(&meta_path, None, properties, attributes)?;
|
||
let content = crate::json::to_vec_pretty_sorted(&meta)
|
||
.context("Failed to serialize meta.json5")?;
|
||
self.suppress_path(&meta_path);
|
||
fs::write(&meta_path, &content)
|
||
.with_context(|| format!("Failed to write {}", meta_path.display()))?;
|
||
|
||
log::info!(
|
||
"Syncback: Persisted non-Source properties to {}",
|
||
meta_path.display()
|
||
);
|
||
} else {
|
||
// Non-script standalone file. Only .model.json5/.model.json support
|
||
// in-place JSON property updates. Other file types (.txt, .csv, .toml,
|
||
// .yaml, etc.) require an adjacent .meta.json5 file — writing JSON
|
||
// directly to them would corrupt their content.
|
||
let file_name = inst_path.file_name().and_then(|f| f.to_str()).unwrap_or("");
|
||
let is_model_file =
|
||
file_name.ends_with(".model.json5") || file_name.ends_with(".model.json");
|
||
|
||
if is_model_file {
|
||
let meta = self.merge_or_build_meta(
|
||
inst_path,
|
||
Some(class_name.as_str()),
|
||
properties,
|
||
attributes,
|
||
)?;
|
||
let content = crate::json::to_vec_pretty_sorted(&meta)
|
||
.context("Failed to serialize model file")?;
|
||
self.suppress_path(inst_path);
|
||
fs::write(inst_path, &content)
|
||
.with_context(|| format!("Failed to write {}", inst_path.display()))?;
|
||
|
||
log::info!(
|
||
"Syncback: Persisted non-Source properties to {}",
|
||
inst_path.display()
|
||
);
|
||
} else {
|
||
// For .txt, .csv, .toml, .yaml, etc. — use adjacent meta file
|
||
let parent_dir = inst_path.parent().context("No parent directory")?;
|
||
let file_stem = inst_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||
let meta_path = parent_dir.join(format!("{}.meta.json5", file_stem));
|
||
|
||
let meta = self.merge_or_build_meta(&meta_path, None, properties, attributes)?;
|
||
let content = crate::json::to_vec_pretty_sorted(&meta)
|
||
.context("Failed to serialize meta.json5")?;
|
||
self.suppress_path(&meta_path);
|
||
fs::write(&meta_path, &content)
|
||
.with_context(|| format!("Failed to write {}", meta_path.display()))?;
|
||
|
||
log::info!(
|
||
"Syncback: Persisted non-Source properties to {}",
|
||
meta_path.display()
|
||
);
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Reads an existing meta/model JSON5 file and merges new properties into it.
|
||
/// If the file doesn't exist, builds a fresh meta object.
|
||
fn merge_or_build_meta(
|
||
&self,
|
||
existing_path: &std::path::Path,
|
||
class_name: Option<&str>,
|
||
new_properties: indexmap::IndexMap<String, serde_json::Value>,
|
||
new_attributes: indexmap::IndexMap<String, serde_json::Value>,
|
||
) -> anyhow::Result<serde_json::Value> {
|
||
use anyhow::Context;
|
||
|
||
if existing_path.exists() {
|
||
// Read and parse existing file
|
||
let content = fs::read(existing_path)
|
||
.with_context(|| format!("Failed to read {}", existing_path.display()))?;
|
||
let mut existing: serde_json::Value =
|
||
json5::from_str(&String::from_utf8_lossy(&content))
|
||
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
|
||
|
||
// Ensure the existing value is an object
|
||
if !existing.is_object() {
|
||
existing = serde_json::Value::Object(serde_json::Map::new());
|
||
}
|
||
let obj = existing.as_object_mut().unwrap();
|
||
|
||
// Merge properties
|
||
if !new_properties.is_empty() {
|
||
let props = obj
|
||
.entry("properties")
|
||
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
|
||
if let Some(props_obj) = props.as_object_mut() {
|
||
for (k, v) in new_properties {
|
||
props_obj.insert(k, v);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Merge attributes
|
||
if !new_attributes.is_empty() {
|
||
let attrs = obj
|
||
.entry("attributes")
|
||
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
|
||
if let Some(attrs_obj) = attrs.as_object_mut() {
|
||
for (k, v) in new_attributes {
|
||
attrs_obj.insert(k, v);
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(existing)
|
||
} else {
|
||
// No existing file — build from scratch
|
||
Ok(self.build_meta_object(class_name, new_properties, new_attributes))
|
||
}
|
||
}
|
||
|
||
async fn handle_api_read(&self, request: Request<Body>) -> Response<Body> {
|
||
let argument = &request.uri().path()["/api/read/".len()..];
|
||
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect();
|
||
|
||
let requested_ids = match requested_ids {
|
||
Ok(ids) => ids,
|
||
Err(_) => {
|
||
return msgpack(
|
||
ErrorResponse::bad_request("Malformed ID list"),
|
||
StatusCode::BAD_REQUEST,
|
||
);
|
||
}
|
||
};
|
||
|
||
let message_queue = self.serve_session.message_queue();
|
||
let message_cursor = message_queue.cursor();
|
||
|
||
let tree = self.serve_session.tree();
|
||
let scripts_only = self.serve_session.sync_scripts_only();
|
||
|
||
let mut instances = HashMap::new();
|
||
|
||
if scripts_only {
|
||
// In scripts-only mode, we need to:
|
||
// 1. Find all scripts
|
||
// 2. Include their ancestor chains (so the tree structure is valid)
|
||
// 3. Mark non-script ancestors with ignoreUnknownInstances: true
|
||
// 4. Filter children to only include instances in our set
|
||
|
||
let mut included_ids: HashSet<Ref> = HashSet::new();
|
||
|
||
// First pass: collect all script IDs and their ancestors
|
||
for id in &requested_ids {
|
||
if let Some(instance) = tree.get_instance(*id) {
|
||
collect_scripts_and_ancestors(&tree, instance.id(), &mut included_ids);
|
||
for descendant in tree.descendants(*id) {
|
||
collect_scripts_and_ancestors(&tree, descendant.id(), &mut included_ids);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Second pass: create Instance objects for included IDs
|
||
for id in &requested_ids {
|
||
if let Some(instance) = tree.get_instance(*id) {
|
||
if included_ids.contains(id) {
|
||
let inst = instance_for_scripts_only(instance, &included_ids);
|
||
instances.insert(*id, inst);
|
||
}
|
||
|
||
for descendant in tree.descendants(*id) {
|
||
let desc_id = descendant.id();
|
||
if included_ids.contains(&desc_id) {
|
||
let inst = instance_for_scripts_only(descendant, &included_ids);
|
||
instances.insert(desc_id, inst);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Normal mode: include all instances
|
||
for id in requested_ids {
|
||
if let Some(instance) = tree.get_instance(id) {
|
||
instances.insert(id, Instance::from_rojo_instance(instance));
|
||
|
||
for descendant in tree.descendants(id) {
|
||
instances.insert(descendant.id(), Instance::from_rojo_instance(descendant));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
msgpack_ok(ReadResponse {
|
||
session_id: self.serve_session.session_id(),
|
||
message_cursor,
|
||
instances,
|
||
})
|
||
}
|
||
|
||
/// Accepts a list of IDs and returns them serialized as a binary model.
|
||
/// The model is sent in a schema that causes Roblox to deserialize it as
|
||
/// a Luau `buffer`.
|
||
///
|
||
/// The returned model is a folder that contains ObjectValues with names
|
||
/// that correspond to the requested Instances. These values have their
|
||
/// `Value` property set to point to the requested Instance.
|
||
async fn handle_api_serialize(&self, request: Request<Body>) -> Response<Body> {
|
||
let argument = &request.uri().path()["/api/serialize/".len()..];
|
||
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect();
|
||
|
||
let requested_ids = match requested_ids {
|
||
Ok(ids) => ids,
|
||
Err(_) => {
|
||
return msgpack(
|
||
ErrorResponse::bad_request("Malformed ID list"),
|
||
StatusCode::BAD_REQUEST,
|
||
);
|
||
}
|
||
};
|
||
|
||
let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder"));
|
||
|
||
let tree = self.serve_session.tree();
|
||
for id in &requested_ids {
|
||
if let Some(instance) = tree.get_instance(*id) {
|
||
let clone = response_dom.insert(
|
||
Ref::none(),
|
||
InstanceBuilder::new(instance.class_name())
|
||
.with_name(instance.name())
|
||
.with_properties(instance.properties().clone()),
|
||
);
|
||
let object_value = response_dom.insert(
|
||
response_dom.root_ref(),
|
||
InstanceBuilder::new("ObjectValue")
|
||
.with_name(id.to_string())
|
||
.with_property("Value", clone),
|
||
);
|
||
|
||
let mut child_ref = clone;
|
||
if let Some(parent_class) = parent_requirements(&instance.class_name()) {
|
||
child_ref =
|
||
response_dom.insert(object_value, InstanceBuilder::new(parent_class));
|
||
response_dom.transfer_within(clone, child_ref);
|
||
}
|
||
|
||
response_dom.transfer_within(child_ref, object_value);
|
||
} else {
|
||
msgpack(
|
||
ErrorResponse::bad_request(format!("provided id {id} is not in the tree")),
|
||
StatusCode::BAD_REQUEST,
|
||
);
|
||
}
|
||
}
|
||
drop(tree);
|
||
|
||
let mut source = Vec::new();
|
||
rbx_binary::to_writer(&mut source, &response_dom, &[response_dom.root_ref()]).unwrap();
|
||
|
||
msgpack_ok(SerializeResponse {
|
||
session_id: self.serve_session.session_id(),
|
||
model_contents: source,
|
||
})
|
||
}
|
||
|
||
/// Returns a list of all referent properties that point towards the
|
||
/// provided IDs. Used because the plugin does not store a RojoTree,
|
||
/// and referent properties need to be updated after the serialize
|
||
/// endpoint is used.
|
||
async fn handle_api_ref_patch(&self, request: Request<Body>) -> Response<Body> {
|
||
let argument = &request.uri().path()["/api/ref-patch/".len()..];
|
||
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect();
|
||
|
||
let requested_ids = match requested_ids {
|
||
Ok(ids) => ids,
|
||
Err(_) => {
|
||
return msgpack(
|
||
ErrorResponse::bad_request("Malformed ID list"),
|
||
StatusCode::BAD_REQUEST,
|
||
);
|
||
}
|
||
};
|
||
|
||
// Convert to HashSet for efficient lookup
|
||
let ids: HashSet<Ref> = requested_ids.into_iter().collect();
|
||
|
||
let mut instance_updates: HashMap<Ref, InstanceUpdate> = HashMap::new();
|
||
|
||
let tree = self.serve_session.tree();
|
||
for instance in tree.descendants(tree.get_root_id()) {
|
||
for (prop_name, prop_value) in instance.properties() {
|
||
let Variant::Ref(prop_value) = prop_value else {
|
||
continue;
|
||
};
|
||
if let Some(target_id) = ids.get(prop_value) {
|
||
let instance_id = instance.id();
|
||
let update =
|
||
instance_updates
|
||
.entry(instance_id)
|
||
.or_insert_with(|| InstanceUpdate {
|
||
id: instance_id,
|
||
changed_class_name: None,
|
||
changed_name: None,
|
||
changed_metadata: None,
|
||
changed_properties: UstrMap::default(),
|
||
});
|
||
update
|
||
.changed_properties
|
||
.insert(*prop_name, Some(Variant::Ref(*target_id)));
|
||
}
|
||
}
|
||
}
|
||
|
||
msgpack_ok(RefPatchResponse {
|
||
session_id: self.serve_session.session_id(),
|
||
patch: SubscribeMessage {
|
||
added: HashMap::new(),
|
||
removed: Vec::new(),
|
||
updated: instance_updates.into_values().collect(),
|
||
},
|
||
})
|
||
}
|
||
|
||
/// Open a script with the given ID in the user's default text editor.
|
||
async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
|
||
let argument = &request.uri().path()["/api/open/".len()..];
|
||
let requested_id = match Ref::from_str(argument) {
|
||
Ok(id) => id,
|
||
Err(_) => {
|
||
return msgpack(
|
||
ErrorResponse::bad_request("Invalid instance ID"),
|
||
StatusCode::BAD_REQUEST,
|
||
);
|
||
}
|
||
};
|
||
|
||
let tree = self.serve_session.tree();
|
||
|
||
let instance = match tree.get_instance(requested_id) {
|
||
Some(instance) => instance,
|
||
None => {
|
||
return msgpack(
|
||
ErrorResponse::bad_request("Instance not found"),
|
||
StatusCode::NOT_FOUND,
|
||
);
|
||
}
|
||
};
|
||
|
||
let script_path = match pick_script_path(instance) {
|
||
Some(path) => path,
|
||
None => {
|
||
return msgpack(
|
||
ErrorResponse::bad_request(
|
||
"No appropriate file could be found to open this script",
|
||
),
|
||
StatusCode::NOT_FOUND,
|
||
);
|
||
}
|
||
};
|
||
|
||
match opener::open(&script_path) {
|
||
Ok(()) => {}
|
||
Err(error) => match error {
|
||
OpenError::Io(io_error) => {
|
||
return msgpack(
|
||
ErrorResponse::internal_error(format!(
|
||
"Attempting to open {} failed because of the following io error: {}",
|
||
script_path.display(),
|
||
io_error
|
||
)),
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
)
|
||
}
|
||
OpenError::ExitStatus {
|
||
cmd,
|
||
status,
|
||
stderr,
|
||
} => {
|
||
return msgpack(
|
||
ErrorResponse::internal_error(format!(
|
||
r#"The command '{}' to open '{}' failed with the error code '{}'.
|
||
Error logs:
|
||
{}"#,
|
||
cmd,
|
||
script_path.display(),
|
||
status,
|
||
stderr
|
||
)),
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
)
|
||
}
|
||
_ => {
|
||
return msgpack(
|
||
ErrorResponse::internal_error(format!(
|
||
"Failed to open {}: {}",
|
||
script_path.display(),
|
||
error
|
||
)),
|
||
StatusCode::INTERNAL_SERVER_ERROR,
|
||
)
|
||
}
|
||
},
|
||
};
|
||
|
||
msgpack_ok(OpenResponse {
|
||
session_id: self.serve_session.session_id(),
|
||
})
|
||
}
|
||
}
|
||
|
||
/// If this instance is represented by a script, try to find the correct .luau
|
||
/// file to open to edit it.
|
||
fn pick_script_path(instance: InstanceWithMeta<'_>) -> Option<PathBuf> {
|
||
match instance.class_name().as_str() {
|
||
"Script" | "LocalScript" | "ModuleScript" => {}
|
||
_ => return None,
|
||
}
|
||
|
||
// Pick the first listed relevant path that has an extension of .luau that
|
||
// exists.
|
||
instance
|
||
.metadata()
|
||
.relevant_paths
|
||
.iter()
|
||
.find(|path| {
|
||
// We should only ever open Luau files to be safe.
|
||
match path.extension().and_then(|ext| ext.to_str()) {
|
||
Some("luau") => {}
|
||
_ => return false,
|
||
}
|
||
|
||
fs::metadata(path)
|
||
.map(|meta| meta.is_file())
|
||
.unwrap_or(false)
|
||
})
|
||
.map(|path| path.to_owned())
|
||
}
|
||
|
||
/// Handle WebSocket connection for streaming subscription messages
|
||
async fn handle_websocket_subscription(
|
||
serve_session: Arc<ServeSession>,
|
||
websocket: HyperWebsocket,
|
||
input_cursor: u32,
|
||
) -> anyhow::Result<()> {
|
||
let mut websocket = websocket.await?;
|
||
|
||
let session_id = serve_session.session_id();
|
||
let tree_handle = serve_session.tree_handle();
|
||
let message_queue = serve_session.message_queue();
|
||
let scripts_only = serve_session.sync_scripts_only();
|
||
|
||
log::debug!(
|
||
"WebSocket subscription established for session {}",
|
||
session_id
|
||
);
|
||
|
||
// Now continuously listen for new messages using select to handle both incoming messages
|
||
// and WebSocket control messages concurrently
|
||
let mut cursor = input_cursor;
|
||
loop {
|
||
let receiver = message_queue.subscribe(cursor);
|
||
|
||
tokio::select! {
|
||
// Handle new messages from the message queue
|
||
result = receiver => {
|
||
match result {
|
||
Ok((new_cursor, messages)) => {
|
||
if !messages.is_empty() {
|
||
let msgpack_message = {
|
||
let tree = tree_handle.lock().unwrap();
|
||
let api_messages: Vec<_> = messages
|
||
.into_iter()
|
||
.map(|patch| {
|
||
let mut msg = SubscribeMessage::from_patch_update(&tree, patch);
|
||
// In scripts-only mode, transform to only include scripts
|
||
// and their necessary ancestors
|
||
if scripts_only {
|
||
filter_subscribe_message_for_scripts(&tree, &mut msg);
|
||
}
|
||
msg
|
||
})
|
||
.collect();
|
||
|
||
let response = SocketPacket {
|
||
session_id,
|
||
packet_type: SocketPacketType::Messages,
|
||
body: SocketPacketBody::Messages(MessagesPacket {
|
||
message_cursor: new_cursor,
|
||
messages: api_messages,
|
||
}),
|
||
};
|
||
|
||
serialize_msgpack(response)?
|
||
};
|
||
|
||
log::debug!("Sending batch of messages over WebSocket subscription");
|
||
|
||
if websocket.send(Message::Binary(msgpack_message)).await.is_err() {
|
||
// Client disconnected
|
||
log::debug!("WebSocket subscription closed by client");
|
||
break;
|
||
}
|
||
cursor = new_cursor;
|
||
}
|
||
}
|
||
Err(_) => {
|
||
// Message queue disconnected
|
||
log::debug!("Message queue disconnected; closing WebSocket subscription");
|
||
let _ = websocket.send(Message::Close(None)).await;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle incoming WebSocket messages (ping/pong/close)
|
||
msg = websocket.next() => {
|
||
match msg {
|
||
Some(Ok(Message::Close(_))) => {
|
||
log::debug!("WebSocket subscription closed by client");
|
||
break;
|
||
}
|
||
Some(Ok(Message::Ping(data))) => {
|
||
// tungstenite handles pong automatically
|
||
log::debug!("Received ping: {:?}", data);
|
||
}
|
||
Some(Ok(Message::Pong(data))) => {
|
||
log::debug!("Received pong: {:?}", data);
|
||
}
|
||
Some(Ok(Message::Text(_))) | Some(Ok(Message::Binary(_))) => {
|
||
// Ignore text/binary messages from client for subscription endpoint
|
||
// TODO: Use this for bidirectional sync or requesting fallbacks?
|
||
log::debug!("Ignoring message from client since we don't use it for anything yet: {:?}", msg);
|
||
}
|
||
Some(Ok(Message::Frame(_))) => {
|
||
// This should never happen according to tungstenite docs
|
||
unreachable!();
|
||
}
|
||
Some(Err(e)) => {
|
||
log::error!("WebSocket error: {}", e);
|
||
break;
|
||
}
|
||
None => {
|
||
// WebSocket stream ended
|
||
log::debug!("WebSocket stream ended");
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Certain Instances MUST be a child of specific classes. This function
|
||
/// tracks that information for the Serialize endpoint.
|
||
///
|
||
/// If a parent requirement exists, it will be returned.
|
||
/// Otherwise returns `None`.
|
||
fn parent_requirements(class: &str) -> Option<&str> {
|
||
Some(match class {
|
||
"Attachment" | "Bone" => "Part",
|
||
"Animator" => "Humanoid",
|
||
"BaseWrap" | "WrapLayer" | "WrapTarget" | "WrapDeformer" => "MeshPart",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Checks if a class name is a script type (Script, LocalScript, ModuleScript).
|
||
#[inline]
|
||
fn is_script_class(class_name: &str) -> bool {
|
||
matches!(class_name, "Script" | "LocalScript" | "ModuleScript")
|
||
}
|
||
|
||
/// Filters a SubscribeMessage to only include scripts and their ancestors.
|
||
/// Non-script ancestors get ignoreUnknownInstances: true.
|
||
fn filter_subscribe_message_for_scripts(
|
||
tree: &crate::snapshot::RojoTree,
|
||
msg: &mut SubscribeMessage<'_>,
|
||
) {
|
||
use std::borrow::Cow;
|
||
|
||
// Build set of IDs that should be included (scripts + their ancestors)
|
||
let mut included_ids: HashSet<Ref> = HashSet::new();
|
||
|
||
// First, find all scripts in the added instances and collect their ancestors
|
||
for (id, inst) in &msg.added {
|
||
if is_script_class(inst.class_name.as_str()) {
|
||
// Add this script and all its ancestors
|
||
included_ids.insert(*id);
|
||
let mut current_parent = inst.parent;
|
||
while !current_parent.is_none() {
|
||
if included_ids.contains(¤t_parent) {
|
||
break;
|
||
}
|
||
included_ids.insert(current_parent);
|
||
if let Some(parent_inst) = msg.added.get(¤t_parent) {
|
||
current_parent = parent_inst.parent;
|
||
} else if let Some(tree_inst) = tree.get_instance(current_parent) {
|
||
current_parent = tree_inst.parent();
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Filter added to only include IDs in our set
|
||
msg.added.retain(|id, _| included_ids.contains(id));
|
||
|
||
// ALL instances get ignoreUnknownInstances: true in scripts-only mode
|
||
// This ensures we only sync script content, never delete/modify other instances
|
||
// Non-scripts also have their properties cleared - they're just tree structure
|
||
for inst in msg.added.values_mut() {
|
||
inst.metadata = Some(InstanceMetadata {
|
||
ignore_unknown_instances: true,
|
||
});
|
||
|
||
// Non-scripts don't sync properties - they're just tree structure
|
||
if !is_script_class(inst.class_name.as_str()) {
|
||
inst.properties.clear();
|
||
}
|
||
|
||
// Filter children to only include those in our set
|
||
let filtered_children: Vec<Ref> = inst
|
||
.children
|
||
.iter()
|
||
.copied()
|
||
.filter(|child_id| included_ids.contains(child_id))
|
||
.collect();
|
||
inst.children = Cow::Owned(filtered_children);
|
||
}
|
||
|
||
// Only allow updates to scripts
|
||
msg.updated.retain(|update| {
|
||
tree.get_instance(update.id)
|
||
.map(|inst| is_script_class(inst.class_name().as_str()))
|
||
.unwrap_or(false)
|
||
});
|
||
|
||
// Only remove scripts (plugin will ignore unknown IDs anyway)
|
||
msg.removed.retain(|_id| {
|
||
// We can't check removed instances in the tree since they're gone,
|
||
// so we let all removals through - the plugin will ignore unknown IDs
|
||
true
|
||
});
|
||
}
|
||
|
||
/// Recursively collects a script instance and all its ancestors into the set.
|
||
/// Only adds to the set if the instance is a script (then adds ancestors too).
|
||
fn collect_scripts_and_ancestors(
|
||
tree: &crate::snapshot::RojoTree,
|
||
id: Ref,
|
||
included_ids: &mut HashSet<Ref>,
|
||
) {
|
||
if let Some(instance) = tree.get_instance(id) {
|
||
if is_script_class(instance.class_name().as_str()) {
|
||
// This is a script - add it and all ancestors
|
||
let mut current_id = id;
|
||
while let Some(inst) = tree.get_instance(current_id) {
|
||
included_ids.insert(current_id);
|
||
let parent_id = inst.parent();
|
||
if parent_id.is_none() || included_ids.contains(&parent_id) {
|
||
break;
|
||
}
|
||
current_id = parent_id;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Creates an Instance for scripts-only mode.
|
||
/// - Scripts get their properties synced normally
|
||
/// - Non-scripts only provide tree structure (no properties synced)
|
||
fn instance_for_scripts_only<'a>(
|
||
source: InstanceWithMeta<'a>,
|
||
included_ids: &HashSet<Ref>,
|
||
) -> Instance<'a> {
|
||
use std::borrow::Cow;
|
||
|
||
let is_script = is_script_class(source.class_name().as_str());
|
||
|
||
// Only sync properties for scripts, not for ancestor containers
|
||
let properties = if is_script {
|
||
source
|
||
.properties()
|
||
.iter()
|
||
.filter(|(_key, value)| {
|
||
// Filter out SharedString values (can't be serialized)
|
||
value.ty() != rbx_dom_weak::types::VariantType::SharedString
|
||
})
|
||
.map(|(key, value)| (*key, Cow::Borrowed(value)))
|
||
.collect()
|
||
} else {
|
||
// Non-scripts don't sync any properties - they're just tree structure
|
||
UstrMap::default()
|
||
};
|
||
|
||
// Filter children to only include those in our included set
|
||
let filtered_children: Vec<Ref> = source
|
||
.children()
|
||
.iter()
|
||
.copied()
|
||
.filter(|child_id| included_ids.contains(child_id))
|
||
.collect();
|
||
|
||
// In scripts-only mode, ALL instances get ignoreUnknownInstances: true
|
||
// This ensures we only sync script content, never delete/modify other instances
|
||
let metadata = Some(InstanceMetadata {
|
||
ignore_unknown_instances: true,
|
||
});
|
||
|
||
Instance {
|
||
id: source.id(),
|
||
parent: source.parent(),
|
||
name: Cow::Borrowed(source.name()),
|
||
class_name: source.class_name(),
|
||
properties,
|
||
children: Cow::Owned(filtered_children),
|
||
metadata,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use rbx_dom_weak::types::{Color3, Enum, NumberRange, Rect, UDim, UDim2, Vector2, Vector3};
|
||
|
||
// Tests for variant_to_json function
|
||
mod variant_to_json_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_string() {
|
||
let variant = Variant::String("hello world".to_string());
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
assert_eq!(result.unwrap(), serde_json::json!("hello world"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_bool() {
|
||
assert_eq!(
|
||
variant_to_json(&Variant::Bool(true)),
|
||
Some(serde_json::json!(true))
|
||
);
|
||
assert_eq!(
|
||
variant_to_json(&Variant::Bool(false)),
|
||
Some(serde_json::json!(false))
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_int32() {
|
||
let variant = Variant::Int32(42);
|
||
let result = variant_to_json(&variant);
|
||
assert_eq!(result, Some(serde_json::json!(42)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_int64() {
|
||
let variant = Variant::Int64(9999999999i64);
|
||
let result = variant_to_json(&variant);
|
||
assert_eq!(result, Some(serde_json::json!(9999999999i64)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_float32() {
|
||
let variant = Variant::Float32(3.125); // Use a non-constant value
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_float64() {
|
||
let variant = Variant::Float64(2.5); // Use a non-constant value
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_vector2() {
|
||
let variant = Variant::Vector2(Vector2::new(1.0, 2.0));
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
assert_eq!(result.unwrap(), serde_json::json!([1.0, 2.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn test_vector3() {
|
||
let variant = Variant::Vector3(Vector3::new(1.0, 2.0, 3.0));
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
assert_eq!(result.unwrap(), serde_json::json!([1.0, 2.0, 3.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn test_color3() {
|
||
let variant = Variant::Color3(Color3::new(1.0, 0.5, 0.0));
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
assert_eq!(result.unwrap(), serde_json::json!([1.0, 0.5, 0.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn test_udim() {
|
||
let variant = Variant::UDim(UDim::new(0.5, 100));
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
let json = result.unwrap();
|
||
assert_eq!(json["scale"], 0.5);
|
||
assert_eq!(json["offset"], 100);
|
||
}
|
||
|
||
#[test]
|
||
fn test_udim2() {
|
||
let variant = Variant::UDim2(UDim2::new(UDim::new(0.5, 10), UDim::new(0.25, 20)));
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
let json = result.unwrap();
|
||
assert_eq!(json["x"]["scale"], 0.5);
|
||
assert_eq!(json["x"]["offset"], 10);
|
||
assert_eq!(json["y"]["scale"], 0.25);
|
||
assert_eq!(json["y"]["offset"], 20);
|
||
}
|
||
|
||
#[test]
|
||
fn test_enum() {
|
||
let variant = Variant::Enum(Enum::from_u32(2));
|
||
let result = variant_to_json(&variant);
|
||
assert_eq!(result, Some(serde_json::json!(2)));
|
||
}
|
||
|
||
#[test]
|
||
fn test_number_range() {
|
||
let variant = Variant::NumberRange(NumberRange::new(0.0, 100.0));
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
let json = result.unwrap();
|
||
assert_eq!(json["min"], 0.0);
|
||
assert_eq!(json["max"], 100.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_rect() {
|
||
let variant =
|
||
Variant::Rect(Rect::new(Vector2::new(0.0, 0.0), Vector2::new(100.0, 50.0)));
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_some());
|
||
let json = result.unwrap();
|
||
assert_eq!(json["min"], serde_json::json!([0.0, 0.0]));
|
||
assert_eq!(json["max"], serde_json::json!([100.0, 50.0]));
|
||
}
|
||
|
||
#[test]
|
||
fn test_unsupported_returns_none() {
|
||
// BinaryString is not directly supported in JSON
|
||
let variant = Variant::BinaryString(vec![1, 2, 3].into());
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_none());
|
||
}
|
||
}
|
||
|
||
// Tests for file naming conventions in syncback
|
||
mod syncback_file_naming_tests {
|
||
#[test]
|
||
fn test_script_file_extensions() {
|
||
// Test that script class names result in correct file extensions
|
||
let test_cases = vec![
|
||
("ModuleScript", "TestModule", "TestModule.luau"),
|
||
("LocalScript", "TestClient", "TestClient.local.luau"),
|
||
];
|
||
|
||
for (class_name, name, expected) in test_cases {
|
||
let file_name = match class_name {
|
||
"ModuleScript" => format!("{}.luau", name),
|
||
"LocalScript" => format!("{}.local.luau", name),
|
||
_ => format!("{}.model.json5", name),
|
||
};
|
||
assert_eq!(
|
||
file_name, expected,
|
||
"File naming for {} '{}' is incorrect",
|
||
class_name, name
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_folder_creates_directory() {
|
||
// Folders should not create .model.json5 files
|
||
let class_name = "Folder";
|
||
let should_create_file = class_name != "Folder";
|
||
assert!(
|
||
!should_create_file,
|
||
"Folder should create a directory, not a file"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_other_instances_create_model_json5() {
|
||
// Part, Frame, TextLabel, Model use .model.json5 (matching dedicated syncback)
|
||
// Configuration creates a directory instead
|
||
let json5_types = vec!["Part", "Frame", "TextLabel", "Model"];
|
||
for class_name in json5_types {
|
||
let file_name = "Test.model.json5";
|
||
assert!(
|
||
file_name.ends_with(".model.json5"),
|
||
"{} should create .model.json5 file",
|
||
class_name
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_directory_classes() {
|
||
// These classes create directories, not files (matching dedicated syncback)
|
||
let dir_classes = vec![
|
||
"Folder",
|
||
"Configuration",
|
||
"Tool",
|
||
"ScreenGui",
|
||
"SurfaceGui",
|
||
"BillboardGui",
|
||
"AdGui",
|
||
];
|
||
for class_name in dir_classes {
|
||
// These should create directories
|
||
assert!(
|
||
matches!(
|
||
class_name,
|
||
"Folder"
|
||
| "Configuration"
|
||
| "Tool"
|
||
| "ScreenGui"
|
||
| "SurfaceGui"
|
||
| "BillboardGui"
|
||
| "AdGui"
|
||
),
|
||
"{} should create a directory",
|
||
class_name
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_special_value_classes() {
|
||
// StringValue -> .txt, LocalizationTable -> .csv
|
||
assert_eq!(
|
||
format!("{}.txt", "TestValue"),
|
||
"TestValue.txt",
|
||
"StringValue should create .txt file"
|
||
);
|
||
assert_eq!(
|
||
format!("{}.csv", "TestLocalization"),
|
||
"TestLocalization.csv",
|
||
"LocalizationTable should create .csv file"
|
||
);
|
||
}
|
||
}
|
||
|
||
// Tests for model.json5 structure
|
||
mod model_json_structure_tests {
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn test_basic_structure() {
|
||
// Verify the expected JSON structure for model files
|
||
let model = json!({
|
||
"className": "Part",
|
||
"properties": {}
|
||
});
|
||
|
||
assert!(model.get("className").is_some());
|
||
assert!(model.get("properties").is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn test_with_properties() {
|
||
let model = json!({
|
||
"className": "Part",
|
||
"properties": {
|
||
"Size": [4.0, 1.0, 2.0],
|
||
"Color": [1.0, 0.0, 0.0],
|
||
"Anchored": true
|
||
}
|
||
});
|
||
|
||
let props = model.get("properties").unwrap();
|
||
assert!(props.get("Size").is_some());
|
||
assert!(props.get("Color").is_some());
|
||
assert!(props.get("Anchored").is_some());
|
||
}
|
||
}
|
||
|
||
// Tests for RunContext-based script file suffix determination
|
||
mod run_context_tests {
|
||
|
||
#[test]
|
||
fn test_run_context_client_gives_client_suffix() {
|
||
// Script with RunContext: Client should produce .client.luau
|
||
// RunContext::Client has value 2 in the Roblox enum
|
||
let run_context_enums = rbx_reflection_database::get()
|
||
.ok()
|
||
.and_then(|db| db.enums.get("RunContext"))
|
||
.map(|e| &e.items);
|
||
|
||
if let Some(enums) = run_context_enums {
|
||
// Verify the enum value exists - just getting it is the test
|
||
assert!(enums.get("Client").is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_run_context_server_gives_server_suffix() {
|
||
// Script with RunContext: Server should produce .server.luau
|
||
let run_context_enums = rbx_reflection_database::get()
|
||
.ok()
|
||
.and_then(|db| db.enums.get("RunContext"))
|
||
.map(|e| &e.items);
|
||
|
||
if let Some(enums) = run_context_enums {
|
||
assert!(enums.get("Server").is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_run_context_legacy_gives_legacy_suffix() {
|
||
// Script with RunContext: Legacy should produce .legacy.luau
|
||
let run_context_enums = rbx_reflection_database::get()
|
||
.ok()
|
||
.and_then(|db| db.enums.get("RunContext"))
|
||
.map(|e| &e.items);
|
||
|
||
if let Some(enums) = run_context_enums {
|
||
assert!(enums.get("Legacy").is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_run_context_plugin_gives_plugin_suffix() {
|
||
// Script with RunContext: Plugin should produce .plugin.luau
|
||
let run_context_enums = rbx_reflection_database::get()
|
||
.ok()
|
||
.and_then(|db| db.enums.get("RunContext"))
|
||
.map(|e| &e.items);
|
||
|
||
if let Some(enums) = run_context_enums {
|
||
assert!(enums.get("Plugin").is_some());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_script_class_determines_suffix_from_run_context() {
|
||
// For Script class, the file suffix is determined by RunContext:
|
||
// - Client → .client.luau
|
||
// - Server → .server.luau
|
||
// - Legacy → .legacy.luau
|
||
// - Plugin → .plugin.luau
|
||
|
||
let expected_mappings = vec![
|
||
("Client", "client"),
|
||
("Server", "server"),
|
||
("Legacy", "legacy"),
|
||
("Plugin", "plugin"),
|
||
];
|
||
|
||
for (run_context, expected_suffix) in expected_mappings {
|
||
let suffix = match run_context {
|
||
"Client" => "client",
|
||
"Server" => "server",
|
||
"Legacy" => "legacy",
|
||
"Plugin" => "plugin",
|
||
_ => "legacy",
|
||
};
|
||
assert_eq!(
|
||
suffix, expected_suffix,
|
||
"RunContext {} should produce {} suffix",
|
||
run_context, expected_suffix
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_local_script_always_local() {
|
||
// LocalScript class always produces .local.luau regardless of any properties
|
||
let class_name = "LocalScript";
|
||
let expected_suffix = "local";
|
||
|
||
// LocalScript doesn't check RunContext - it's always local
|
||
let suffix = if class_name == "LocalScript" {
|
||
"local"
|
||
} else {
|
||
"unknown"
|
||
};
|
||
|
||
assert_eq!(suffix, expected_suffix);
|
||
}
|
||
|
||
#[test]
|
||
fn test_module_script_no_suffix() {
|
||
// ModuleScript produces .luau (no server/client suffix)
|
||
let class_name = "ModuleScript";
|
||
|
||
let expected_extension = if class_name == "ModuleScript" {
|
||
".luau"
|
||
} else {
|
||
".unknown"
|
||
};
|
||
|
||
assert_eq!(expected_extension, ".luau");
|
||
}
|
||
}
|
||
|
||
// Tests for adjacent meta file creation
|
||
mod adjacent_meta_tests {
|
||
#[test]
|
||
fn test_adjacent_meta_file_naming() {
|
||
// Adjacent meta files should be named {script_name}.meta.json5
|
||
let script_name = "MyScript";
|
||
let meta_filename = format!("{}.meta.json5", script_name);
|
||
assert_eq!(meta_filename, "MyScript.meta.json5");
|
||
}
|
||
|
||
#[test]
|
||
fn test_adjacent_meta_for_script_without_children() {
|
||
// Scripts without children should create adjacent meta files, not init.meta.json5
|
||
let has_children = false;
|
||
let is_directory = has_children;
|
||
|
||
// When it's not a directory, we use adjacent meta, not init.meta.json5
|
||
let meta_type = if is_directory {
|
||
"init.meta.json5"
|
||
} else {
|
||
"{name}.meta.json5"
|
||
};
|
||
|
||
assert_eq!(meta_type, "{name}.meta.json5");
|
||
}
|
||
|
||
#[test]
|
||
fn test_init_meta_for_script_with_children() {
|
||
// Scripts with children (directories) use init.meta.json5
|
||
let has_children = true;
|
||
let is_directory = has_children;
|
||
|
||
let meta_type = if is_directory {
|
||
"init.meta.json5"
|
||
} else {
|
||
"{name}.meta.json5"
|
||
};
|
||
|
||
assert_eq!(meta_type, "init.meta.json5");
|
||
}
|
||
}
|
||
|
||
// Tests for property filtering (matching dedicated syncback)
|
||
mod property_filter_tests {
|
||
use super::*;
|
||
use rbx_dom_weak::types::{Attributes, Ref, UniqueId};
|
||
|
||
#[test]
|
||
fn test_ref_properties_filtered_out() {
|
||
// Ref properties should be filtered out (they don't serialize to JSON)
|
||
let variant = Variant::Ref(Ref::new());
|
||
let result = variant_to_json(&variant);
|
||
// variant_to_json returns None for Ref
|
||
assert!(result.is_none(), "Ref should not serialize to JSON");
|
||
}
|
||
|
||
#[test]
|
||
fn test_unique_id_filtered_out() {
|
||
// UniqueId properties should be filtered out
|
||
let variant = Variant::UniqueId(UniqueId::new(0, 0, 0));
|
||
let result = variant_to_json(&variant);
|
||
assert!(result.is_none(), "UniqueId should not serialize to JSON");
|
||
}
|
||
|
||
#[test]
|
||
fn test_attributes_handled_separately() {
|
||
// Attributes are NOT handled by variant_to_json directly.
|
||
// They are extracted into a separate "attributes" map by filter_properties_for_meta.
|
||
// This matches the dedicated syncback behavior where Attributes are a special case.
|
||
let mut attrs = Attributes::new();
|
||
attrs.insert("TestAttr".to_string(), Variant::String("value".to_string()));
|
||
|
||
let variant = Variant::Attributes(attrs);
|
||
let result = variant_to_json(&variant);
|
||
// variant_to_json returns None for Attributes - they're handled separately
|
||
assert!(
|
||
result.is_none(),
|
||
"Attributes should return None from variant_to_json (handled separately)"
|
||
);
|
||
}
|
||
}
|
||
|
||
// Tests for init.meta.json5 format (matching dedicated syncback)
|
||
mod meta_json5_tests {
|
||
use serde_json::json;
|
||
|
||
#[test]
|
||
fn test_meta_uses_json5_extension() {
|
||
// Meta files should use .json5 extension (not .json)
|
||
let filename = "init.meta.json5";
|
||
assert!(
|
||
filename.ends_with(".json5"),
|
||
"Meta files should use .json5 extension"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_meta_structure_for_directory() {
|
||
// Directory meta files should have properties and optionally attributes
|
||
let meta = json!({
|
||
"properties": {
|
||
"Archivable": false
|
||
},
|
||
"attributes": {
|
||
"CustomAttr": "value"
|
||
}
|
||
});
|
||
|
||
assert!(meta.get("properties").is_some());
|
||
assert!(meta.get("attributes").is_some());
|
||
// No className for standard Folders
|
||
assert!(meta.get("className").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn test_meta_structure_for_non_folder() {
|
||
// Non-Folder directories need className
|
||
let meta = json!({
|
||
"className": "Part",
|
||
"properties": {
|
||
"Size": [4.0, 1.0, 2.0]
|
||
}
|
||
});
|
||
|
||
assert!(meta.get("className").is_some());
|
||
assert_eq!(meta.get("className").unwrap(), "Part");
|
||
}
|
||
}
|
||
|
||
// Tests for children handling (any instance can have children)
|
||
mod children_handling_tests {
|
||
#[allow(unused_imports)]
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_script_with_children_becomes_directory() {
|
||
// When a script has children, it should become a directory with init file
|
||
let has_children = true;
|
||
let class_name = "ModuleScript";
|
||
|
||
// This is the expected behavior - directory + init.luau
|
||
if has_children && class_name == "ModuleScript" {
|
||
let init_file = "init.luau";
|
||
assert_eq!(init_file, "init.luau");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_server_script_with_children() {
|
||
let has_children = true;
|
||
let class_name = "Script";
|
||
|
||
if has_children && class_name == "Script" {
|
||
let init_file = "init.server.luau";
|
||
assert_eq!(init_file, "init.server.luau");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_client_script_with_children() {
|
||
let has_children = true;
|
||
let class_name = "LocalScript";
|
||
|
||
if has_children && class_name == "LocalScript" {
|
||
let init_file = "init.client.luau";
|
||
assert_eq!(init_file, "init.client.luau");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_string_value_with_children() {
|
||
// StringValue with children becomes directory + init.meta.json5
|
||
let has_children = true;
|
||
let class_name = "StringValue";
|
||
|
||
if has_children && class_name == "StringValue" {
|
||
// Should create directory with init.meta.json5, not .txt file
|
||
let meta_file = "init.meta.json5";
|
||
assert!(meta_file.ends_with(".json5"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_part_with_children() {
|
||
// Part (or any other class) with children becomes directory + init.meta.json5
|
||
let has_children = true;
|
||
let class_name = "Part";
|
||
|
||
if has_children && !matches!(class_name, "ModuleScript" | "Script" | "LocalScript") {
|
||
let meta_file = "init.meta.json5";
|
||
assert!(meta_file.ends_with(".json5"));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Tests for .gitkeep behavior
|
||
mod gitkeep_tests {
|
||
#[test]
|
||
fn test_empty_folder_gets_gitkeep() {
|
||
// Empty folders with no metadata should get .gitkeep (matching dedicated syncback)
|
||
let has_children = false;
|
||
let has_metadata = false;
|
||
|
||
let should_add_gitkeep = !has_children && !has_metadata;
|
||
assert!(should_add_gitkeep, "Empty folder should get .gitkeep");
|
||
}
|
||
|
||
#[test]
|
||
fn test_folder_with_metadata_no_gitkeep() {
|
||
// Folders with metadata should NOT get .gitkeep
|
||
let has_children = false;
|
||
let has_metadata = true;
|
||
|
||
let should_add_gitkeep = !has_children && !has_metadata;
|
||
assert!(
|
||
!should_add_gitkeep,
|
||
"Folder with metadata should not get .gitkeep"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_folder_with_children_no_gitkeep() {
|
||
// Folders with children should NOT get .gitkeep
|
||
let has_children = true;
|
||
let has_metadata = false;
|
||
|
||
let should_add_gitkeep = !has_children && !has_metadata;
|
||
assert!(
|
||
!should_add_gitkeep,
|
||
"Folder with children should not get .gitkeep"
|
||
);
|
||
}
|
||
}
|
||
|
||
// Tests for attributes handling (matching dedicated syncback)
|
||
mod attributes_tests {
|
||
use super::*;
|
||
use rbx_dom_weak::types::Attributes;
|
||
|
||
#[test]
|
||
fn test_internal_attributes_filtered() {
|
||
// Internal attributes starting with RBX should be filtered out
|
||
let mut attrs = Attributes::new();
|
||
attrs.insert(
|
||
"RBXInternal".to_string(),
|
||
Variant::String("internal".to_string()),
|
||
);
|
||
attrs.insert(
|
||
"CustomAttr".to_string(),
|
||
Variant::String("custom".to_string()),
|
||
);
|
||
|
||
// Only CustomAttr should be kept
|
||
let mut count = 0;
|
||
for (name, _) in attrs.iter() {
|
||
if !name.starts_with("RBX") {
|
||
count += 1;
|
||
}
|
||
}
|
||
assert_eq!(count, 1, "Only non-RBX attributes should be kept");
|
||
}
|
||
|
||
#[test]
|
||
fn test_user_attributes_preserved() {
|
||
// User-defined attributes should be preserved
|
||
let mut attrs = Attributes::new();
|
||
attrs.insert(
|
||
"MyAttribute".to_string(),
|
||
Variant::String("value".to_string()),
|
||
);
|
||
attrs.insert("Score".to_string(), Variant::Int32(100));
|
||
|
||
let filtered: Vec<_> = attrs
|
||
.iter()
|
||
.filter(|(name, _)| !name.starts_with("RBX"))
|
||
.collect();
|
||
|
||
assert_eq!(filtered.len(), 2, "User attributes should be preserved");
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Comprehensive RunContext Configuration Tests
|
||
// =========================================================================
|
||
mod run_context_comprehensive_tests {
|
||
/// Helper to get RunContext enum value by name
|
||
fn get_run_context_value(name: &str) -> Option<u32> {
|
||
rbx_reflection_database::get()
|
||
.ok()
|
||
.and_then(|db| db.enums.get("RunContext"))
|
||
.and_then(|e| e.items.get(name).copied())
|
||
}
|
||
|
||
/// Simulates suffix determination logic (matches get_script_suffix_for_run_context)
|
||
fn determine_suffix_from_run_context(run_context_value: Option<u32>) -> &'static str {
|
||
let run_context_enums = rbx_reflection_database::get()
|
||
.ok()
|
||
.and_then(|db| db.enums.get("RunContext"))
|
||
.map(|e| &e.items);
|
||
|
||
if let (Some(enums), Some(value)) = (run_context_enums, run_context_value) {
|
||
for (name, &enum_value) in enums {
|
||
if enum_value == value {
|
||
return match name.as_ref() {
|
||
"Client" => "client",
|
||
"Server" => "server",
|
||
"Legacy" => "legacy",
|
||
"Plugin" => "plugin",
|
||
_ => "legacy",
|
||
};
|
||
}
|
||
}
|
||
}
|
||
"legacy"
|
||
}
|
||
|
||
#[test]
|
||
fn test_all_run_context_values_exist() {
|
||
// Verify all expected RunContext values exist in the reflection database
|
||
let expected = ["Legacy", "Server", "Client", "Plugin"];
|
||
for name in expected {
|
||
assert!(
|
||
get_run_context_value(name).is_some(),
|
||
"RunContext::{} should exist in reflection database",
|
||
name
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_run_context_client_produces_client_suffix() {
|
||
let value = get_run_context_value("Client");
|
||
assert!(value.is_some(), "Client RunContext should exist");
|
||
let suffix = determine_suffix_from_run_context(value);
|
||
assert_eq!(
|
||
suffix, "client",
|
||
"RunContext::Client should produce 'client' suffix"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_run_context_server_produces_server_suffix() {
|
||
let value = get_run_context_value("Server");
|
||
assert!(value.is_some(), "Server RunContext should exist");
|
||
let suffix = determine_suffix_from_run_context(value);
|
||
assert_eq!(
|
||
suffix, "server",
|
||
"RunContext::Server should produce 'server' suffix"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_run_context_legacy_produces_legacy_suffix() {
|
||
let value = get_run_context_value("Legacy");
|
||
assert!(value.is_some(), "Legacy RunContext should exist");
|
||
let suffix = determine_suffix_from_run_context(value);
|
||
assert_eq!(
|
||
suffix, "legacy",
|
||
"RunContext::Legacy should produce 'legacy' suffix"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_run_context_plugin_produces_plugin_suffix() {
|
||
let value = get_run_context_value("Plugin");
|
||
assert!(value.is_some(), "Plugin RunContext should exist");
|
||
let suffix = determine_suffix_from_run_context(value);
|
||
assert_eq!(
|
||
suffix, "plugin",
|
||
"RunContext::Plugin should produce 'plugin' suffix"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_no_run_context_defaults_to_legacy() {
|
||
let suffix = determine_suffix_from_run_context(None);
|
||
assert_eq!(
|
||
suffix, "legacy",
|
||
"No RunContext should default to 'legacy' suffix"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_invalid_run_context_defaults_to_legacy() {
|
||
// Some unlikely value that doesn't match any known RunContext
|
||
let suffix = determine_suffix_from_run_context(Some(99999));
|
||
assert_eq!(
|
||
suffix, "legacy",
|
||
"Invalid RunContext should default to 'legacy' suffix"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_file_extension_patterns() {
|
||
// Verify expected file extension patterns
|
||
let test_cases = vec![
|
||
("ModuleScript", None, ".luau"),
|
||
("LocalScript", None, ".local.luau"),
|
||
("Script", get_run_context_value("Server"), ".server.luau"),
|
||
("Script", get_run_context_value("Client"), ".client.luau"),
|
||
("Script", get_run_context_value("Legacy"), ".legacy.luau"),
|
||
("Script", get_run_context_value("Plugin"), ".plugin.luau"),
|
||
("Script", None, ".legacy.luau"),
|
||
];
|
||
|
||
for (class_name, run_context, expected_ext) in test_cases {
|
||
let suffix = match class_name {
|
||
"ModuleScript" => "",
|
||
"LocalScript" => "local",
|
||
"Script" => determine_suffix_from_run_context(run_context),
|
||
_ => panic!("Unknown class"),
|
||
};
|
||
|
||
let ext = if suffix.is_empty() {
|
||
".luau".to_string()
|
||
} else {
|
||
format!(".{}.luau", suffix)
|
||
};
|
||
|
||
assert_eq!(
|
||
ext, expected_ext,
|
||
"{} with RunContext {:?} should produce {} extension",
|
||
class_name, run_context, expected_ext
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_init_file_patterns_with_children() {
|
||
// Verify init file patterns for scripts with children
|
||
let test_cases = vec![
|
||
("ModuleScript", None, "init.luau"),
|
||
("LocalScript", None, "init.local.luau"),
|
||
(
|
||
"Script",
|
||
get_run_context_value("Server"),
|
||
"init.server.luau",
|
||
),
|
||
(
|
||
"Script",
|
||
get_run_context_value("Client"),
|
||
"init.client.luau",
|
||
),
|
||
(
|
||
"Script",
|
||
get_run_context_value("Legacy"),
|
||
"init.legacy.luau",
|
||
),
|
||
(
|
||
"Script",
|
||
get_run_context_value("Plugin"),
|
||
"init.plugin.luau",
|
||
),
|
||
];
|
||
|
||
for (class_name, run_context, expected_init) in test_cases {
|
||
let suffix = match class_name {
|
||
"ModuleScript" => "",
|
||
"LocalScript" => "local",
|
||
"Script" => determine_suffix_from_run_context(run_context),
|
||
_ => panic!("Unknown class"),
|
||
};
|
||
|
||
let init_file = if suffix.is_empty() {
|
||
"init.luau".to_string()
|
||
} else {
|
||
format!("init.{}.luau", suffix)
|
||
};
|
||
|
||
assert_eq!(
|
||
init_file, expected_init,
|
||
"{} with RunContext {:?} should produce {} init file",
|
||
class_name, run_context, expected_init
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Scripts-Only Mode Filtering Tests
|
||
// =========================================================================
|
||
mod scripts_only_mode_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_is_script_class_identifies_scripts() {
|
||
// Test that is_script_class correctly identifies script types
|
||
assert!(
|
||
is_script_class("Script"),
|
||
"Script should be identified as script"
|
||
);
|
||
assert!(
|
||
is_script_class("LocalScript"),
|
||
"LocalScript should be identified as script"
|
||
);
|
||
assert!(
|
||
is_script_class("ModuleScript"),
|
||
"ModuleScript should be identified as script"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_is_script_class_rejects_non_scripts() {
|
||
// Test that is_script_class correctly rejects non-script types
|
||
let non_scripts = [
|
||
"Part",
|
||
"Folder",
|
||
"Model",
|
||
"Frame",
|
||
"TextLabel",
|
||
"IntValue",
|
||
"StringValue",
|
||
"Configuration",
|
||
"ScreenGui",
|
||
"Workspace",
|
||
"ReplicatedStorage",
|
||
];
|
||
|
||
for class_name in non_scripts {
|
||
assert!(
|
||
!is_script_class(class_name),
|
||
"{} should NOT be identified as script",
|
||
class_name
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_should_include_script() {
|
||
// In scripts-only mode, Script instances should be included
|
||
let class_name = "Script";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
should_include,
|
||
"Script should be included in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_should_include_local_script() {
|
||
// In scripts-only mode, LocalScript instances should be included
|
||
let class_name = "LocalScript";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
should_include,
|
||
"LocalScript should be included in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_should_include_module_script() {
|
||
// In scripts-only mode, ModuleScript instances should be included
|
||
let class_name = "ModuleScript";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
should_include,
|
||
"ModuleScript should be included in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_should_exclude_part() {
|
||
// In scripts-only mode, Part instances should be excluded
|
||
let class_name = "Part";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"Part should NOT be included in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_should_exclude_folder() {
|
||
// In scripts-only mode, Folder instances should be excluded (unless ancestor)
|
||
let class_name = "Folder";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"Folder should NOT be directly included in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_should_exclude_model() {
|
||
// In scripts-only mode, Model instances should be excluded (unless ancestor)
|
||
let class_name = "Model";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"Model should NOT be directly included in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_should_exclude_gui_elements() {
|
||
// In scripts-only mode, GUI elements should be excluded
|
||
let gui_classes = [
|
||
"Frame",
|
||
"TextLabel",
|
||
"TextButton",
|
||
"ImageLabel",
|
||
"ScreenGui",
|
||
];
|
||
for class_name in gui_classes {
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"{} should NOT be included in scripts-only mode",
|
||
class_name
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_should_exclude_value_types() {
|
||
// In scripts-only mode, value types should be excluded
|
||
let value_classes = [
|
||
"IntValue",
|
||
"StringValue",
|
||
"NumberValue",
|
||
"BoolValue",
|
||
"ObjectValue",
|
||
];
|
||
for class_name in value_classes {
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"{} should NOT be included in scripts-only mode",
|
||
class_name
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_run_context_preserved() {
|
||
// In scripts-only mode, RunContext property on scripts should be preserved
|
||
// This is important for proper syncback behavior
|
||
let script_properties = ["Source", "RunContext", "Enabled", "Disabled"];
|
||
let is_script = is_script_class("Script");
|
||
assert!(is_script, "Script should be identified");
|
||
|
||
// In scripts-only mode, scripts get ALL their properties synced (including RunContext)
|
||
// Non-scripts get their properties cleared
|
||
// This test verifies the design decision
|
||
for prop in script_properties {
|
||
// Scripts should sync these properties
|
||
let should_sync = is_script;
|
||
assert!(
|
||
should_sync,
|
||
"Script property '{}' should be synced in scripts-only mode",
|
||
prop
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_only_ancestor_properties_cleared() {
|
||
// In scripts-only mode, non-script ancestors have properties cleared
|
||
// They only provide tree structure, not property data
|
||
let folder_is_script = is_script_class("Folder");
|
||
assert!(!folder_is_script, "Folder is not a script");
|
||
|
||
// Non-scripts should have properties cleared
|
||
let should_clear_properties = !folder_is_script;
|
||
assert!(
|
||
should_clear_properties,
|
||
"Non-script ancestors should have properties cleared"
|
||
);
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Scripts-Only Mode - Expected Pass Tests
|
||
// =========================================================================
|
||
mod scripts_only_expected_passes {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_pass_module_script_sync() {
|
||
// PASS: ModuleScript should sync successfully in scripts-only mode
|
||
let class_name = "ModuleScript";
|
||
assert!(
|
||
is_script_class(class_name),
|
||
"ModuleScript sync should PASS in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_pass_server_script_sync() {
|
||
// PASS: Script (server) should sync successfully in scripts-only mode
|
||
let class_name = "Script";
|
||
assert!(
|
||
is_script_class(class_name),
|
||
"Script sync should PASS in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_pass_local_script_sync() {
|
||
// PASS: LocalScript should sync successfully in scripts-only mode
|
||
let class_name = "LocalScript";
|
||
assert!(
|
||
is_script_class(class_name),
|
||
"LocalScript sync should PASS in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_pass_script_source_property() {
|
||
// PASS: Script Source property changes should sync
|
||
let class_name = "Script";
|
||
let property_name = "Source";
|
||
let is_script = is_script_class(class_name);
|
||
let should_sync_property = is_script; // Scripts sync all properties
|
||
assert!(
|
||
should_sync_property,
|
||
"Script.{} change should PASS in scripts-only mode",
|
||
property_name
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_pass_script_run_context_property() {
|
||
// PASS: Script RunContext property changes should sync
|
||
// This is critical for proper emitLegacyScripts support
|
||
let class_name = "Script";
|
||
let property_name = "RunContext";
|
||
let is_script = is_script_class(class_name);
|
||
let should_sync_property = is_script; // Scripts sync all properties
|
||
assert!(
|
||
should_sync_property,
|
||
"Script.{} change should PASS in scripts-only mode",
|
||
property_name
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_pass_script_enabled_property() {
|
||
// PASS: Script Enabled property changes should sync
|
||
let class_name = "Script";
|
||
let property_name = "Enabled";
|
||
let is_script = is_script_class(class_name);
|
||
let should_sync_property = is_script;
|
||
assert!(
|
||
should_sync_property,
|
||
"Script.{} change should PASS in scripts-only mode",
|
||
property_name
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_pass_module_script_attributes() {
|
||
// PASS: ModuleScript Attributes should sync
|
||
let class_name = "ModuleScript";
|
||
let is_script = is_script_class(class_name);
|
||
assert!(
|
||
is_script,
|
||
"ModuleScript.Attributes change should PASS in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_pass_nested_script_in_folder() {
|
||
// PASS: Script inside Folder should sync (along with Folder as ancestor)
|
||
let script_class = "ModuleScript";
|
||
let folder_class = "Folder";
|
||
|
||
let script_included = is_script_class(script_class);
|
||
let folder_is_script = is_script_class(folder_class);
|
||
|
||
// Script should be included directly
|
||
assert!(script_included, "Nested script should be included");
|
||
// Folder should be included as ancestor (but not as a script)
|
||
assert!(
|
||
!folder_is_script,
|
||
"Folder is not a script but may be included as ancestor"
|
||
);
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Scripts-Only Mode - Expected Failure/Filter Tests
|
||
// =========================================================================
|
||
mod scripts_only_expected_filters {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_filter_part_changes() {
|
||
// FILTER: Part changes should be filtered out in scripts-only mode
|
||
let class_name = "Part";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"Part changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_model_changes() {
|
||
// FILTER: Model changes should be filtered out in scripts-only mode
|
||
let class_name = "Model";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"Model changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_folder_property_changes() {
|
||
// FILTER: Folder property changes should be filtered (Folder is ancestor only)
|
||
let class_name = "Folder";
|
||
let is_script = is_script_class(class_name);
|
||
// Folders have their properties cleared in scripts-only mode
|
||
assert!(
|
||
!is_script,
|
||
"Folder property changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_frame_changes() {
|
||
// FILTER: Frame (GUI) changes should be filtered
|
||
let class_name = "Frame";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"Frame changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_text_label_changes() {
|
||
// FILTER: TextLabel changes should be filtered
|
||
let class_name = "TextLabel";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"TextLabel changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_int_value_changes() {
|
||
// FILTER: IntValue changes should be filtered
|
||
let class_name = "IntValue";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"IntValue changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_string_value_changes() {
|
||
// FILTER: StringValue changes should be filtered
|
||
let class_name = "StringValue";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"StringValue changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_sound_changes() {
|
||
// FILTER: Sound changes should be filtered
|
||
let class_name = "Sound";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"Sound changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_particle_emitter_changes() {
|
||
// FILTER: ParticleEmitter changes should be filtered
|
||
let class_name = "ParticleEmitter";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"ParticleEmitter changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_configuration_changes() {
|
||
// FILTER: Configuration instance changes should be filtered
|
||
let class_name = "Configuration";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"Configuration changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_remote_event_changes() {
|
||
// FILTER: RemoteEvent changes should be filtered
|
||
let class_name = "RemoteEvent";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"RemoteEvent changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_remote_function_changes() {
|
||
// FILTER: RemoteFunction changes should be filtered
|
||
let class_name = "RemoteFunction";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"RemoteFunction changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_filter_bindable_event_changes() {
|
||
// FILTER: BindableEvent changes should be filtered
|
||
let class_name = "BindableEvent";
|
||
let should_include = is_script_class(class_name);
|
||
assert!(
|
||
!should_include,
|
||
"BindableEvent changes should be FILTERED in scripts-only mode"
|
||
);
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Scripts-Only Mode - Ancestor Inclusion Tests
|
||
// =========================================================================
|
||
mod scripts_only_ancestor_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_ancestor_chain_concept() {
|
||
// Test the concept of ancestor chain inclusion
|
||
// In scripts-only mode:
|
||
// - Scripts are included with all properties
|
||
// - Non-script ancestors are included for tree structure but properties cleared
|
||
|
||
// Simulated tree: ReplicatedStorage > Folder > ModuleScript
|
||
let replicated_storage = "ReplicatedStorage";
|
||
let folder = "Folder";
|
||
let module_script = "ModuleScript";
|
||
|
||
// Only the script is identified as a script
|
||
assert!(!is_script_class(replicated_storage));
|
||
assert!(!is_script_class(folder));
|
||
assert!(is_script_class(module_script));
|
||
|
||
// But all three should be included in the message (for tree structure)
|
||
// The filtering function includes ancestors of scripts
|
||
}
|
||
|
||
#[test]
|
||
fn test_non_script_ancestors_have_ignore_unknown_instances() {
|
||
// In scripts-only mode, all instances get ignoreUnknownInstances: true
|
||
// This prevents accidental deletion of non-script instances
|
||
let folder = "Folder";
|
||
let _is_script = is_script_class(folder);
|
||
|
||
// Non-scripts should have ignoreUnknownInstances set
|
||
let should_set_ignore = true; // All instances in scripts-only mode
|
||
assert!(
|
||
should_set_ignore,
|
||
"Non-script ancestors should have ignoreUnknownInstances: true"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_scripts_also_have_ignore_unknown_instances() {
|
||
// Even scripts get ignoreUnknownInstances: true in scripts-only mode
|
||
// This is a safety measure
|
||
let script = "Script";
|
||
let is_script = is_script_class(script);
|
||
assert!(is_script);
|
||
|
||
// All instances in scripts-only mode get this flag
|
||
let should_set_ignore = true;
|
||
assert!(
|
||
should_set_ignore,
|
||
"Scripts should also have ignoreUnknownInstances: true in scripts-only mode"
|
||
);
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Syncback Configuration Matrix Tests
|
||
// =========================================================================
|
||
mod syncback_configuration_matrix {
|
||
/// Test matrix for all script class + RunContext combinations
|
||
#[test]
|
||
fn test_configuration_matrix() {
|
||
struct TestCase {
|
||
class_name: &'static str,
|
||
run_context: Option<&'static str>,
|
||
expected_extension: &'static str,
|
||
description: &'static str,
|
||
}
|
||
|
||
let test_cases = vec![
|
||
// ModuleScript - always .luau, no RunContext matters
|
||
TestCase {
|
||
class_name: "ModuleScript",
|
||
run_context: None,
|
||
expected_extension: ".luau",
|
||
description: "ModuleScript without RunContext",
|
||
},
|
||
// LocalScript - always .local.luau
|
||
TestCase {
|
||
class_name: "LocalScript",
|
||
run_context: None,
|
||
expected_extension: ".local.luau",
|
||
description: "LocalScript",
|
||
},
|
||
// Script with various RunContext values
|
||
TestCase {
|
||
class_name: "Script",
|
||
run_context: None,
|
||
expected_extension: ".legacy.luau",
|
||
description: "Script without RunContext (default)",
|
||
},
|
||
TestCase {
|
||
class_name: "Script",
|
||
run_context: Some("Server"),
|
||
expected_extension: ".server.luau",
|
||
description: "Script with RunContext::Server",
|
||
},
|
||
TestCase {
|
||
class_name: "Script",
|
||
run_context: Some("Client"),
|
||
expected_extension: ".client.luau",
|
||
description: "Script with RunContext::Client",
|
||
},
|
||
TestCase {
|
||
class_name: "Script",
|
||
run_context: Some("Legacy"),
|
||
expected_extension: ".legacy.luau",
|
||
description: "Script with RunContext::Legacy",
|
||
},
|
||
TestCase {
|
||
class_name: "Script",
|
||
run_context: Some("Plugin"),
|
||
expected_extension: ".plugin.luau",
|
||
description: "Script with RunContext::Plugin",
|
||
},
|
||
];
|
||
|
||
for case in test_cases {
|
||
let suffix = match case.class_name {
|
||
"ModuleScript" => String::new(),
|
||
"LocalScript" => "local".to_string(),
|
||
"Script" => {
|
||
// Match on RunContext name to determine suffix
|
||
match case.run_context {
|
||
Some("Client") => "client",
|
||
Some("Server") => "server",
|
||
Some("Legacy") => "legacy",
|
||
Some("Plugin") => "plugin",
|
||
None => "legacy",
|
||
_ => "legacy",
|
||
}
|
||
.to_string()
|
||
}
|
||
_ => panic!("Unknown class"),
|
||
};
|
||
|
||
let extension = if suffix.is_empty() {
|
||
".luau".to_string()
|
||
} else {
|
||
format!(".{}.luau", suffix)
|
||
};
|
||
|
||
assert_eq!(
|
||
extension, case.expected_extension,
|
||
"FAILED: {} - got {} expected {}",
|
||
case.description, extension, case.expected_extension
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_new_script_naming_convention() {
|
||
// Test that syncback produces the correct file extensions with the new naming convention
|
||
//
|
||
// New naming convention (emitLegacyScripts removed):
|
||
// - LocalScript -> .local.luau
|
||
// - Script + RunContext::Server -> .server.luau
|
||
// - Script + RunContext::Client -> .client.luau
|
||
// - Script + RunContext::Plugin -> .plugin.luau
|
||
// - Script + RunContext::Legacy -> .legacy.luau
|
||
// - Script (no RunContext) -> .legacy.luau (default)
|
||
//
|
||
// Backwards compatibility for READING old files:
|
||
// - .client.lua -> LocalScript
|
||
// - .server.lua -> Script with RunContext::Legacy
|
||
|
||
// Test expected syncback extensions
|
||
let test_cases = vec![
|
||
("LocalScript", None, ".local.luau"),
|
||
("Script", Some("Server"), ".server.luau"),
|
||
("Script", Some("Client"), ".client.luau"),
|
||
("Script", Some("Plugin"), ".plugin.luau"),
|
||
("Script", Some("Legacy"), ".legacy.luau"),
|
||
("Script", None, ".legacy.luau"),
|
||
];
|
||
|
||
for (class, run_context, expected_ext) in &test_cases {
|
||
let syncback_ext = match (*class, *run_context) {
|
||
("LocalScript", _) => ".local.luau",
|
||
("Script", Some("Server")) => ".server.luau",
|
||
("Script", Some("Client")) => ".client.luau",
|
||
("Script", Some("Plugin")) => ".plugin.luau",
|
||
("Script", Some("Legacy")) => ".legacy.luau",
|
||
("Script", None) => ".legacy.luau",
|
||
_ => panic!("Unexpected combo"),
|
||
};
|
||
assert_eq!(
|
||
syncback_ext, *expected_ext,
|
||
"New naming convention failed for {} {:?}",
|
||
class, run_context
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Duplicate Path Detection Tests
|
||
// =========================================================================
|
||
mod duplicate_path_tests {
|
||
// Helper to simulate sibling name checking
|
||
fn has_duplicate_sibling_names(names: &[&str], target_name: &str) -> bool {
|
||
let count = names.iter().filter(|&&n| n == target_name).count();
|
||
count > 1
|
||
}
|
||
|
||
// Simulates the duplicate children filter logic
|
||
fn filter_duplicate_names<'a>(names: &[&'a str]) -> Vec<&'a str> {
|
||
let mut counts: std::collections::HashMap<&str, usize> =
|
||
std::collections::HashMap::new();
|
||
for name in names {
|
||
*counts.entry(name).or_insert(0) += 1;
|
||
}
|
||
|
||
names
|
||
.iter()
|
||
.filter(|&&name| counts.get(name).copied().unwrap_or(0) <= 1)
|
||
.copied()
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn test_no_duplicates_all_pass() {
|
||
let names = vec!["Script1", "Script2", "Script3", "Folder"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(filtered.len(), 4, "All unique names should pass");
|
||
}
|
||
|
||
#[test]
|
||
fn test_duplicate_siblings_filtered() {
|
||
let names = vec!["Script", "Script", "Other"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
1,
|
||
"Duplicate 'Script' entries should be filtered"
|
||
);
|
||
assert_eq!(filtered[0], "Other", "Only 'Other' should remain");
|
||
}
|
||
|
||
#[test]
|
||
fn test_multiple_duplicates_filtered() {
|
||
let names = vec!["A", "A", "B", "B", "C"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(filtered.len(), 1, "Only unique 'C' should remain");
|
||
assert_eq!(filtered[0], "C");
|
||
}
|
||
|
||
#[test]
|
||
fn test_all_duplicates_empty_result() {
|
||
let names = vec!["Same", "Same", "Same"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
0,
|
||
"All duplicates should result in empty list"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_sibling_detection_positive() {
|
||
let siblings = vec!["Script", "Script", "Other"];
|
||
assert!(
|
||
has_duplicate_sibling_names(&siblings, "Script"),
|
||
"'Script' should be detected as having duplicate siblings"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_sibling_detection_negative() {
|
||
let siblings = vec!["Script", "Folder", "Model"];
|
||
assert!(
|
||
!has_duplicate_sibling_names(&siblings, "Script"),
|
||
"'Script' should NOT be detected as duplicate with unique siblings"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_sibling_detection_not_in_list() {
|
||
let siblings = vec!["A", "B", "C"];
|
||
assert!(
|
||
!has_duplicate_sibling_names(&siblings, "D"),
|
||
"'D' not in list should not have duplicates"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_path_uniqueness_concept() {
|
||
// Test the concept: path is unique only if NO level has duplicates
|
||
//
|
||
// Tree:
|
||
// Root
|
||
// ├─ FolderA
|
||
// │ └─ Script
|
||
// └─ FolderA (duplicate!)
|
||
// └─ Script
|
||
//
|
||
// Path "Root/FolderA/Script" is NOT unique because FolderA is duplicated
|
||
|
||
let root_children = vec!["FolderA", "FolderA"];
|
||
let folder_children = vec!["Script"];
|
||
|
||
// Check at root level - duplicates exist
|
||
let has_dup_at_root = has_duplicate_sibling_names(&root_children, "FolderA");
|
||
assert!(
|
||
has_dup_at_root,
|
||
"FolderA should be detected as duplicate at root level"
|
||
);
|
||
|
||
// Check at folder level - no duplicates
|
||
let has_dup_at_folder = has_duplicate_sibling_names(&folder_children, "Script");
|
||
assert!(
|
||
!has_dup_at_folder,
|
||
"Script has no duplicate siblings within its parent"
|
||
);
|
||
|
||
// But the PATH is still not unique because of the ancestor duplicate
|
||
let path_is_unique = !has_dup_at_root;
|
||
assert!(
|
||
!path_is_unique,
|
||
"Path should NOT be unique due to ancestor duplicate"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_path_uniqueness_deep_hierarchy() {
|
||
// Tree:
|
||
// Root (unique)
|
||
// └─ Level1 (unique)
|
||
// └─ Level2 (has duplicate sibling)
|
||
// └─ Target
|
||
//
|
||
// Path to Target is NOT unique
|
||
|
||
let level1_children = vec!["Level2", "Level2", "Other"];
|
||
let level2_children = vec!["Target"];
|
||
|
||
let has_dup_at_level1 = has_duplicate_sibling_names(&level1_children, "Level2");
|
||
assert!(has_dup_at_level1, "Level2 has duplicate at Level1");
|
||
|
||
// Even though Target itself has no duplicates, path is not unique
|
||
let has_dup_at_level2 = has_duplicate_sibling_names(&level2_children, "Target");
|
||
assert!(!has_dup_at_level2, "Target has no duplicate at Level2");
|
||
|
||
// Path uniqueness requires ALL levels to be unique
|
||
let path_unique = !has_dup_at_level1 && !has_dup_at_level2;
|
||
assert!(
|
||
!path_unique,
|
||
"Path should NOT be unique due to Level2 duplicate"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_path_uniqueness_fully_unique() {
|
||
// Tree:
|
||
// Root
|
||
// └─ Folder (unique)
|
||
// └─ SubFolder (unique)
|
||
// └─ Script (unique)
|
||
|
||
let root_children = vec!["Folder"];
|
||
let folder_children = vec!["SubFolder"];
|
||
let sub_children = vec!["Script"];
|
||
|
||
let root_unique = !has_duplicate_sibling_names(&root_children, "Folder");
|
||
let folder_unique = !has_duplicate_sibling_names(&folder_children, "SubFolder");
|
||
let sub_unique = !has_duplicate_sibling_names(&sub_children, "Script");
|
||
|
||
let path_unique = root_unique && folder_unique && sub_unique;
|
||
assert!(
|
||
path_unique,
|
||
"Path should be unique when all levels are unique"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_case_sensitive_duplicates() {
|
||
// Roblox names are case-sensitive, so "Script" and "script" are different
|
||
let names = vec!["Script", "script", "SCRIPT"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
3,
|
||
"Case-different names should all be kept (case-sensitive)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_empty_names_handled() {
|
||
let names = vec!["", "", "Valid"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
1,
|
||
"Empty string duplicates should be filtered"
|
||
);
|
||
assert_eq!(filtered[0], "Valid");
|
||
}
|
||
|
||
#[test]
|
||
fn test_special_characters_in_names() {
|
||
// Names with special characters that might cause filesystem issues
|
||
// are handled separately by validate_file_name, but duplicates should still work
|
||
let names = vec!["Script (1)", "Script (2)", "Script (1)"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
1,
|
||
"Only 'Script (2)' should remain after filtering duplicates"
|
||
);
|
||
assert_eq!(filtered[0], "Script (2)");
|
||
}
|
||
|
||
#[test]
|
||
fn test_whitespace_names() {
|
||
// Names with leading/trailing whitespace are technically different
|
||
let names = vec!["Script", " Script", "Script "];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
3,
|
||
"Whitespace-different names should all be kept"
|
||
);
|
||
}
|
||
|
||
// =====================================================================
|
||
// Non-Script Duplicate Tests - Duplicates apply to ALL instance types
|
||
// =====================================================================
|
||
|
||
#[test]
|
||
fn test_duplicate_folders_filtered() {
|
||
// Duplicate Folders should be filtered just like scripts
|
||
let names = vec!["MyFolder", "MyFolder", "OtherFolder"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(filtered.len(), 1, "Duplicate 'MyFolder' should be filtered");
|
||
assert_eq!(filtered[0], "OtherFolder");
|
||
}
|
||
|
||
#[test]
|
||
fn test_duplicate_parts_filtered() {
|
||
// Duplicate Parts should be filtered
|
||
let names = vec!["Part", "Part", "Baseplate"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(filtered.len(), 1, "Duplicate 'Part' should be filtered");
|
||
assert_eq!(filtered[0], "Baseplate");
|
||
}
|
||
|
||
#[test]
|
||
fn test_duplicate_models_filtered() {
|
||
// Duplicate Models should be filtered
|
||
let names = vec!["Car", "Car", "House"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
1,
|
||
"Duplicate 'Car' models should be filtered"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_duplicate_values_filtered() {
|
||
// Duplicate IntValues/StringValues should be filtered
|
||
let names = vec!["Score", "Score", "Health"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
1,
|
||
"Duplicate 'Score' values should be filtered"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_duplicate_gui_elements_filtered() {
|
||
// Duplicate GUI elements should be filtered
|
||
let names = vec!["MainFrame", "MainFrame", "Sidebar"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
1,
|
||
"Duplicate 'MainFrame' GUI should be filtered"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_mixed_types_same_name_filtered() {
|
||
// Even if types differ, same names are duplicates (filesystem perspective)
|
||
// E.g., a Folder named "Data" and a Script named "Data" would conflict
|
||
let names = vec!["Data", "Data"]; // Could be Folder + Script
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
0,
|
||
"Same-named instances of different types still conflict"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_folder_hierarchy_duplicates() {
|
||
// Duplicates in folder hierarchies
|
||
// Root/
|
||
// ├─ Enemies/
|
||
// │ └─ Enemy (unique)
|
||
// └─ Enemies/ (duplicate!)
|
||
// └─ Enemy
|
||
|
||
let root_children = vec!["Enemies", "Enemies", "Players"];
|
||
let enemies_unique = !has_duplicate_sibling_names(&root_children, "Enemies");
|
||
assert!(
|
||
!enemies_unique,
|
||
"Duplicate 'Enemies' folders should be detected"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_part_ancestor_duplicates() {
|
||
// A Part deep in hierarchy with duplicate ancestor
|
||
// Workspace/
|
||
// ├─ Zone/
|
||
// │ └─ SpawnPoint (Part)
|
||
// └─ Zone/ (duplicate!)
|
||
// └─ SpawnPoint (Part)
|
||
|
||
let workspace_children = vec!["Zone", "Zone"];
|
||
let zone_unique = !has_duplicate_sibling_names(&workspace_children, "Zone");
|
||
assert!(
|
||
!zone_unique,
|
||
"Duplicate 'Zone' ancestors make Part paths ambiguous"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_remote_event_duplicates() {
|
||
// RemoteEvents with duplicate names
|
||
let names = vec!["FireBullet", "FireBullet", "TakeDamage"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
1,
|
||
"Duplicate RemoteEvents should be filtered"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_configuration_duplicates() {
|
||
// Configuration instances with duplicates
|
||
let names = vec!["Settings", "Settings", "Config"];
|
||
let filtered = filter_duplicate_names(&names);
|
||
assert_eq!(
|
||
filtered.len(),
|
||
1,
|
||
"Duplicate Configurations should be filtered"
|
||
);
|
||
}
|
||
}
|
||
|
||
// =========================================================================
|
||
// Duplicate Path Detection - Integration Scenarios
|
||
// =========================================================================
|
||
mod duplicate_path_integration_tests {
|
||
|
||
#[test]
|
||
fn test_syncback_skips_duplicate_children() {
|
||
// When syncing back children, duplicates should be skipped
|
||
// This test verifies the filter_duplicate_children logic
|
||
|
||
let children_names = ["ChildA", "ChildB", "ChildA", "ChildC"];
|
||
let unique: Vec<_> = children_names
|
||
.iter()
|
||
.filter(|&&name| children_names.iter().filter(|&&n| n == name).count() == 1)
|
||
.collect();
|
||
|
||
assert_eq!(unique.len(), 2, "ChildB and ChildC should be kept");
|
||
assert!(unique.contains(&&"ChildB"));
|
||
assert!(unique.contains(&&"ChildC"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_recursive_duplicate_handling() {
|
||
// Test that duplicates at any level are handled
|
||
//
|
||
// Parent/
|
||
// ├─ Child1/
|
||
// │ ├─ GrandChild (duplicate)
|
||
// │ └─ GrandChild (duplicate)
|
||
// └─ Child2/
|
||
// └─ GrandChild (unique within its parent)
|
||
|
||
let child1_grandchildren = ["GrandChild", "GrandChild"];
|
||
let child2_grandchildren = ["GrandChild"];
|
||
|
||
// Child1's grandchildren have duplicates
|
||
let child1_filtered: Vec<_> = child1_grandchildren
|
||
.iter()
|
||
.filter(|&&name| child1_grandchildren.iter().filter(|&&n| n == name).count() == 1)
|
||
.collect();
|
||
assert_eq!(
|
||
child1_filtered.len(),
|
||
0,
|
||
"Child1's duplicate grandchildren filtered"
|
||
);
|
||
|
||
// Child2's grandchild is unique
|
||
let child2_filtered: Vec<_> = child2_grandchildren
|
||
.iter()
|
||
.filter(|&&name| child2_grandchildren.iter().filter(|&&n| n == name).count() == 1)
|
||
.collect();
|
||
assert_eq!(child2_filtered.len(), 1, "Child2's unique grandchild kept");
|
||
}
|
||
|
||
#[test]
|
||
fn test_duplicate_log_message_format() {
|
||
// Verify the expected log message format for skipped duplicates
|
||
let class_name = "ModuleScript";
|
||
let full_name = "game.ReplicatedStorage.DuplicateScript";
|
||
|
||
let expected_msg = format!(
|
||
"Skipped instance '{}' ({}) - path contains duplicate-named siblings (cannot reliably sync)",
|
||
full_name, class_name
|
||
);
|
||
|
||
assert!(expected_msg.contains(full_name));
|
||
assert!(expected_msg.contains(class_name));
|
||
assert!(expected_msg.contains("duplicate-named siblings"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_parent_path_validation() {
|
||
// When pulling an instance, we must verify the parent path is unique
|
||
// This simulates the is_tree_path_unique check
|
||
|
||
// Scenario: Trying to add a script under a folder that has duplicate siblings
|
||
let parent_path_levels = [
|
||
("Root", vec!["FolderA", "FolderA"]), // Duplicate at this level!
|
||
];
|
||
|
||
let path_unique = parent_path_levels
|
||
.iter()
|
||
.all(|(target, siblings)| siblings.iter().filter(|&&s| s == *target).count() == 1);
|
||
|
||
assert!(
|
||
!path_unique,
|
||
"Parent path with duplicates should fail validation"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_valid_parent_path() {
|
||
// Valid parent path with no duplicates at any level
|
||
let parent_path_levels = [
|
||
(
|
||
"ReplicatedStorage",
|
||
vec!["Workspace", "ReplicatedStorage", "ServerStorage"],
|
||
),
|
||
("Shared", vec!["Shared", "Client", "Server"]),
|
||
];
|
||
|
||
let path_unique = parent_path_levels
|
||
.iter()
|
||
.all(|(target, siblings)| siblings.iter().filter(|&&s| s == *target).count() == 1);
|
||
|
||
assert!(path_unique, "Valid parent path should pass validation");
|
||
}
|
||
}
|
||
|
||
// Tests for syncback_removed_instance
|
||
mod syncback_removed_tests {
|
||
use crate::snapshot::{InstanceMetadata, InstanceSnapshot, RojoTree};
|
||
use std::fs::{self, File};
|
||
use std::io::Write;
|
||
use tempfile::tempdir;
|
||
|
||
#[test]
|
||
fn test_remove_file_deletes_from_filesystem() {
|
||
// Create a temporary directory and file
|
||
let temp_dir = tempdir().expect("Failed to create temp dir");
|
||
let file_path = temp_dir.path().join("TestModule.luau");
|
||
|
||
// Create the file
|
||
let mut file = File::create(&file_path).expect("Failed to create test file");
|
||
file.write_all(b"return {}")
|
||
.expect("Failed to write to file");
|
||
|
||
// Verify file exists
|
||
assert!(file_path.exists(), "Test file should exist before removal");
|
||
|
||
// Create a tree with an instance pointing to this file
|
||
let snapshot = InstanceSnapshot::new()
|
||
.name("TestModule")
|
||
.class_name("ModuleScript")
|
||
.metadata(InstanceMetadata::new().instigating_source(file_path.clone()));
|
||
|
||
let tree = RojoTree::new(snapshot);
|
||
let root_id = tree.root().id();
|
||
|
||
// Create a minimal ApiContext to call the method
|
||
// Since syncback_removed_instance only needs the tree, we can test the core logic
|
||
// by directly checking what the function would do
|
||
|
||
// Get the instance and verify path resolution
|
||
let instance = tree.get_instance(root_id).expect("Instance should exist");
|
||
let instance_path = instance
|
||
.metadata()
|
||
.instigating_source
|
||
.as_ref()
|
||
.map(|s| s.path())
|
||
.expect("Instance should have path");
|
||
|
||
assert_eq!(instance_path, file_path.as_path());
|
||
|
||
// Now delete the file (simulating what syncback_removed_instance does)
|
||
fs::remove_file(instance_path).expect("Failed to remove file");
|
||
|
||
// Verify file was deleted
|
||
assert!(
|
||
!file_path.exists(),
|
||
"Test file should be deleted after removal"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_remove_directory_deletes_recursively() {
|
||
// Create a temporary directory structure
|
||
let temp_dir = tempdir().expect("Failed to create temp dir");
|
||
let dir_path = temp_dir.path().join("TestFolder");
|
||
fs::create_dir(&dir_path).expect("Failed to create test dir");
|
||
|
||
// Create some files inside
|
||
let child_file = dir_path.join("child.luau");
|
||
File::create(&child_file)
|
||
.expect("Failed to create child file")
|
||
.write_all(b"return {}")
|
||
.expect("Failed to write");
|
||
|
||
let nested_dir = dir_path.join("nested");
|
||
fs::create_dir(&nested_dir).expect("Failed to create nested dir");
|
||
let nested_file = nested_dir.join("deep.luau");
|
||
File::create(&nested_file)
|
||
.expect("Failed to create nested file")
|
||
.write_all(b"return {}")
|
||
.expect("Failed to write");
|
||
|
||
// Verify structure exists
|
||
assert!(dir_path.exists(), "Test dir should exist");
|
||
assert!(child_file.exists(), "Child file should exist");
|
||
assert!(nested_file.exists(), "Nested file should exist");
|
||
|
||
// Create a tree with an instance pointing to this directory
|
||
let snapshot = InstanceSnapshot::new()
|
||
.name("TestFolder")
|
||
.class_name("Folder")
|
||
.metadata(InstanceMetadata::new().instigating_source(dir_path.clone()));
|
||
|
||
let tree = RojoTree::new(snapshot);
|
||
let root_id = tree.root().id();
|
||
|
||
// Get the instance path
|
||
let instance = tree.get_instance(root_id).expect("Instance should exist");
|
||
let instance_path = instance
|
||
.metadata()
|
||
.instigating_source
|
||
.as_ref()
|
||
.map(|s| s.path())
|
||
.expect("Instance should have path");
|
||
|
||
// Delete the directory recursively
|
||
fs::remove_dir_all(instance_path).expect("Failed to remove directory");
|
||
|
||
// Verify everything was deleted
|
||
assert!(!dir_path.exists(), "Directory should be deleted");
|
||
assert!(!child_file.exists(), "Child file should be deleted");
|
||
assert!(!nested_file.exists(), "Nested file should be deleted");
|
||
}
|
||
|
||
#[test]
|
||
fn test_remove_script_also_removes_adjacent_meta() {
|
||
// Create a temporary directory
|
||
let temp_dir = tempdir().expect("Failed to create temp dir");
|
||
let script_path = temp_dir.path().join("MyScript.server.luau");
|
||
let meta_path = temp_dir.path().join("MyScript.meta.json5");
|
||
|
||
// Create both files
|
||
File::create(&script_path)
|
||
.expect("Failed to create script")
|
||
.write_all(b"print('hello')")
|
||
.expect("Failed to write");
|
||
|
||
File::create(&meta_path)
|
||
.expect("Failed to create meta file")
|
||
.write_all(b"{}")
|
||
.expect("Failed to write");
|
||
|
||
// Verify both exist
|
||
assert!(script_path.exists(), "Script should exist");
|
||
assert!(meta_path.exists(), "Meta file should exist");
|
||
|
||
// Create a tree with an instance pointing to the script
|
||
let snapshot = InstanceSnapshot::new()
|
||
.name("MyScript")
|
||
.class_name("Script")
|
||
.metadata(InstanceMetadata::new().instigating_source(script_path.clone()));
|
||
|
||
let tree = RojoTree::new(snapshot);
|
||
let root_id = tree.root().id();
|
||
|
||
// Get the instance
|
||
let instance = tree.get_instance(root_id).expect("Instance should exist");
|
||
let instance_name = instance.name();
|
||
let instance_path = instance
|
||
.metadata()
|
||
.instigating_source
|
||
.as_ref()
|
||
.map(|s| s.path())
|
||
.expect("Instance should have path");
|
||
|
||
// Delete the script file
|
||
fs::remove_file(instance_path).expect("Failed to remove script");
|
||
|
||
// Also remove adjacent meta file (simulating the full syncback_removed_instance logic)
|
||
if let Some(parent_dir) = instance_path.parent() {
|
||
let computed_meta_path = parent_dir.join(format!("{}.meta.json5", instance_name));
|
||
if computed_meta_path.exists() {
|
||
fs::remove_file(&computed_meta_path).expect("Failed to remove meta file");
|
||
}
|
||
}
|
||
|
||
// Verify both were deleted
|
||
assert!(!script_path.exists(), "Script should be deleted");
|
||
assert!(!meta_path.exists(), "Meta file should also be deleted");
|
||
}
|
||
|
||
#[test]
|
||
fn test_meta_path_uses_instance_name_not_file_name() {
|
||
// This test verifies that for "MyScript.server.luau", the meta file
|
||
// is "MyScript.meta.json5" (based on instance name), not "MyScript.server.meta.json5"
|
||
|
||
let instance_name = "MyScript";
|
||
let file_name = "MyScript.server.luau";
|
||
|
||
// The correct meta file name should be based on the instance name
|
||
let correct_meta_name = format!("{}.meta.json5", instance_name);
|
||
assert_eq!(correct_meta_name, "MyScript.meta.json5");
|
||
|
||
// This shows what PathBuf::with_extension would incorrectly produce
|
||
// if we used it on the file path instead of the instance name
|
||
let path = std::path::PathBuf::from(file_name);
|
||
let with_extension_result = path.with_extension("meta.json5");
|
||
let incorrect_meta_name = with_extension_result.to_str().unwrap();
|
||
|
||
assert_eq!(
|
||
incorrect_meta_name, "MyScript.server.meta.json5",
|
||
"with_extension produces wrong meta path for suffixed scripts"
|
||
);
|
||
|
||
// Our implementation should use the instance name instead
|
||
assert_ne!(
|
||
correct_meta_name, incorrect_meta_name,
|
||
"Instance-name-based meta should differ from file-name-based meta for scripts with suffixes"
|
||
);
|
||
|
||
// Verify the correct approach: use instance name, not file path
|
||
assert_eq!(
|
||
correct_meta_name, "MyScript.meta.json5",
|
||
"Correct meta path should be based on instance name"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_instance_without_instigating_source_returns_error() {
|
||
// Create a tree with an instance that has no instigating_source
|
||
let snapshot = InstanceSnapshot::new()
|
||
.name("OrphanInstance")
|
||
.class_name("ModuleScript");
|
||
// Note: no .metadata() call, so instigating_source is None
|
||
|
||
let tree = RojoTree::new(snapshot);
|
||
let root_id = tree.root().id();
|
||
|
||
let instance = tree.get_instance(root_id).expect("Instance should exist");
|
||
|
||
// Verify that instigating_source is None
|
||
assert!(
|
||
instance.metadata().instigating_source.is_none(),
|
||
"Instance should have no instigating_source"
|
||
);
|
||
}
|
||
}
|
||
|
||
mod dir_name_from_script_path_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn module_script_luau() {
|
||
let path = Path::new("src/MyModule.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "MyModule");
|
||
}
|
||
|
||
#[test]
|
||
fn server_script_luau() {
|
||
let path = Path::new("src/Handler.server.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "Handler");
|
||
}
|
||
|
||
#[test]
|
||
fn client_script_luau() {
|
||
let path = Path::new("src/UI.client.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "UI");
|
||
}
|
||
|
||
#[test]
|
||
fn local_script_luau() {
|
||
let path = Path::new("src/Input.local.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "Input");
|
||
}
|
||
|
||
#[test]
|
||
fn plugin_script_luau() {
|
||
let path = Path::new("src/MyPlugin.plugin.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "MyPlugin");
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_script_luau() {
|
||
let path = Path::new("src/OldCode.legacy.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "OldCode");
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_lua_extension() {
|
||
let path = Path::new("src/OldModule.lua");
|
||
assert_eq!(dir_name_from_script_path(path), "OldModule");
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_server_lua() {
|
||
let path = Path::new("src/Main.server.lua");
|
||
assert_eq!(dir_name_from_script_path(path), "Main");
|
||
}
|
||
|
||
#[test]
|
||
fn legacy_client_lua() {
|
||
let path = Path::new("src/Client.client.lua");
|
||
assert_eq!(dir_name_from_script_path(path), "Client");
|
||
}
|
||
|
||
#[test]
|
||
fn encoded_windows_chars_module() {
|
||
// ? is invalid on Windows, encoded as %3F
|
||
let path = Path::new("src/What%3F.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "What%3F");
|
||
}
|
||
|
||
#[test]
|
||
fn encoded_windows_chars_server() {
|
||
let path = Path::new("src/What%3F.server.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "What%3F");
|
||
}
|
||
|
||
#[test]
|
||
fn encoded_colon_char() {
|
||
// : is invalid on Windows, encoded as %3A
|
||
let path = Path::new("src/Key%3AValue.client.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "Key%3AValue");
|
||
}
|
||
|
||
#[test]
|
||
fn multiple_dots_in_name() {
|
||
// Name like "my.module.luau" — file_stem is "my.module", no suffix match
|
||
let path = Path::new("src/my.module.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "my.module");
|
||
}
|
||
|
||
#[test]
|
||
fn name_ending_with_suffix_substring() {
|
||
// "MyServer" should NOT be stripped to "My" — ".server" stripping only
|
||
// applies to the suffix after file_stem strips ".luau"
|
||
let path = Path::new("src/MyServer.luau");
|
||
assert_eq!(dir_name_from_script_path(path), "MyServer");
|
||
}
|
||
}
|
||
|
||
mod dir_name_from_instance_path_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn model_json5() {
|
||
let path = Path::new("src/MyPart.model.json5");
|
||
assert_eq!(dir_name_from_instance_path(path), "MyPart");
|
||
}
|
||
|
||
#[test]
|
||
fn model_json_legacy() {
|
||
let path = Path::new("src/MyPart.model.json");
|
||
assert_eq!(dir_name_from_instance_path(path), "MyPart");
|
||
}
|
||
|
||
#[test]
|
||
fn txt_file() {
|
||
let path = Path::new("src/Greeting.txt");
|
||
assert_eq!(dir_name_from_instance_path(path), "Greeting");
|
||
}
|
||
|
||
#[test]
|
||
fn csv_file() {
|
||
let path = Path::new("src/Translations.csv");
|
||
assert_eq!(dir_name_from_instance_path(path), "Translations");
|
||
}
|
||
|
||
#[test]
|
||
fn encoded_windows_chars_model() {
|
||
let path = Path::new("src/What%3F.model.json5");
|
||
assert_eq!(dir_name_from_instance_path(path), "What%3F");
|
||
}
|
||
|
||
#[test]
|
||
fn encoded_windows_chars_txt() {
|
||
let path = Path::new("src/Ask%3F.txt");
|
||
assert_eq!(dir_name_from_instance_path(path), "Ask%3F");
|
||
}
|
||
|
||
#[test]
|
||
fn encoded_colon_model() {
|
||
let path = Path::new("src/Key%3AValue.model.json5");
|
||
assert_eq!(dir_name_from_instance_path(path), "Key%3AValue");
|
||
}
|
||
|
||
#[test]
|
||
fn encoded_colon_legacy_model() {
|
||
let path = Path::new("src/Key%3AValue.model.json");
|
||
assert_eq!(dir_name_from_instance_path(path), "Key%3AValue");
|
||
}
|
||
|
||
#[test]
|
||
fn multiple_dots_in_name_model() {
|
||
// "my.part.model.json5" — should strip ".model.json5"
|
||
let path = Path::new("src/my.part.model.json5");
|
||
assert_eq!(dir_name_from_instance_path(path), "my.part");
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_extension_uses_file_stem() {
|
||
// Fallback to file_stem for unrecognized extensions
|
||
let path = Path::new("src/Something.toml");
|
||
assert_eq!(dir_name_from_instance_path(path), "Something");
|
||
}
|
||
}
|
||
}
|