mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
- Updated the logic for removing adjacent meta files to correctly derive base names by stripping known script suffixes, improving accuracy in file handling. - Enhanced comments for clarity on the naming resolution process, ensuring better understanding of the changes made to file paths during sync operations.
5113 lines
200 KiB
Rust
5113 lines
200 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>>>,
|
||
}
|
||
|
||
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()
|
||
};
|
||
|
||
// Create the directory
|
||
let new_dir = containing_dir.join(script_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", script_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 new_dir = containing_dir.join(instance_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 (e.g., .model.json5): update in place
|
||
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()
|
||
);
|
||
}
|
||
|
||
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"
|
||
);
|
||
}
|
||
}
|
||
}
|