Add audit plan for slugify branch pass 3 and implement critical fixes

- Introduced a comprehensive audit plan for the slugify branch, detailing findings from a holistic review of 110 files, including 1 critical bug and 2 correctness issues.
- Completed todos addressing model JSON renaming, syncback updates, and test enhancements for roundtrip fidelity.
- Added tests for reserved names and ensured correct handling of slugified instances, preserving compound extensions in model files.
- Refactored change processing logic to correctly manage the `name` field in model files, enhancing metadata handling and ensuring compliance with round-trip identity standards.
This commit is contained in:
Joe 2026-02-11 01:31:09 -06:00
parent 2a917bf8e1
commit bb266789c0
14 changed files with 843 additions and 23 deletions

View file

@ -0,0 +1,162 @@
---
name: Audit slugify pass 3
overview: Holistic audit of the entire `slugify` branch diff against master (110 files, +6315/-1188 lines, 21 commits). Found 1 critical bug, 2 correctness issues, and 8 test gaps. All prior plan fixes verified in code. Legacy cleanup confirmed complete.
todos:
- id: c1-model-rename
content: "CRITICAL: Fix model JSON rename in change_processor.rs -- add .model to suffix list, update name inside model file instead of adjacent meta"
status: completed
- id: cc1-model-update-name
content: Fix syncback_update_existing_instance (api.rs:1093) dropping name field when rewriting .model.json5 files, and write_init_meta_json (api.rs:1075) dropping name field for directories
status: completed
- id: cc2-dir-removal-meta
content: Fix remove_instance_at_path directory case to clean up grandparent-level DirName.meta.json5
status: completed
- id: test-roundtrip-slugified
content: "Add roundtrip test: instances with forbidden chars through syncback -> rebuild -> compare"
status: completed
- id: test-model-rename
content: "Add two-way sync test: rename a .model.json5 instance, verify compound extension preserved and name updated"
status: completed
- id: test-idempotency
content: "Add syncback idempotency integration test: syncback twice = zero changes"
status: completed
- id: test-3-collision
content: Add 3+ instance collision test (X/Y, X:Y, X|Y all slug to X_Y)
status: completed
- id: test-reserved-names
content: Add Windows reserved name integration test (CON, PRN round-trip)
status: completed
- id: test-dir-rename
content: "Add two-way sync test: directory/init-file rename with forbidden chars"
status: completed
- id: test-case-collision
content: Add case-insensitive collision integration test (Foo vs foo)
status: completed
- id: test-meta-empty-deletion
content: Strengthen rename_slugified_to_clean test to verify meta file is deleted (not just name removed) when it becomes empty
status: completed
isProject: false
---
# Audit: slugify Branch -- Pass 3 (Holistic)
## Scope
Full diff against `master`: 110 files, +6,315 / -1,188 lines across 21 commits. Every changed file reviewed. All 4 prior plan files cross-referenced against codebase.
## Quality Standard
> **Round-trip identity**: Syncback (or two-way sync) writes a directory tree. Building an rbxl from that directory tree and forward-syncing it back must produce a **bit-identical instance tree**. Any deviation is a bug.
---
## Prior Plan Verification
All claims from prior plans verified in code -- no discrepancies:
- **fix_audit_findings**: C1 (meta gating), C2 (removal meta path), C3 (deny_unknown_fields), CC1 (determinism), CC2 (init removal), DR1 (shared meta helper), DR2 (meta path helper) -- all confirmed in code.
- **fix_audit_pass_2**: CC2 refactor (`remove_instance_at_path`), preseed fix, rename dedup, dir sort, csv instigating_source, over-suppression, comment fix -- all confirmed.
- **fix_stem-level_dedup**: `return-dedup-key`, `fix-dir`, `fix-project`, `fix-api` -- all confirmed. Note: `fix-tests` is still marked `in_progress` in the plan but tests do exist; the plan status is stale.
---
## Areas Confirmed CLEAN
Thoroughly re-audited via the full diff and found correct:
- **Forward sync** (all middleware): `decode_path_name` fully removed. All middleware correctly resolves names from metadata `name` field or filename stem. `specified_name` set on `InstanceMetadata` in every middleware. `is_empty()` updated for `name`. `instigating_source` preserved in `csv.rs`.
- **Reverse sync / syncback**: `slugify_name()`, `deduplicate_name()`, `name_for_inst()` all correct. Pre-seeding uses `relevant_paths` + `strip_middleware_extension()`. Child ordering sorted alphabetically in both `dir.rs` and `project.rs`. `dedup_key.to_lowercase()` applied at ALL insertion sites.
- **Two-way sync adds**: `syncback_added_instance` groups adds by parent, seeds `sibling_slugs` from tree children via `instigating_source` paths, uses `strip_middleware_extension`. Meta files written when slug differs. Meta path matches script file path via `adjacent_meta_path()`.
- **Two-way sync removals**: `remove_instance_at_path` handles directories, init files (parent dir removal + grandparent meta), and regular files (adjacent meta). Old dead `syncback_removed_instance` deleted.
- **Meta lifecycle**: C1 fix in place -- meta updates OUTSIDE `if new_path != *path` for both init and regular paths. Shared module (`syncback::meta`) used by `change_processor.rs`. `suppress_path_remove` exists. `RemoveNameOutcome` handled correctly with `(1, 0)` counts for `FileDeleted`.
- **Legacy cleanup**: Zero references to old encoding system in source, tests, plugin, schema, or snapshots. `src/path_encoding.rs` deleted. `pub mod path_encoding` removed from `lib.rs`. `decode_windows_invalid_chars` removed from `InstanceContext` and `serve_session.rs`.
- **Dedup key consistency**: All insertion sites use `.to_lowercase()`. Cross-format collision handled.
- **SyncbackRules**: No `deny_unknown_fields`.
- **JSON schema**: `encodeWindowsInvalidChars` removed from `vscode-rojo/schemas/`.
- **Snapshot files**: All `.snap` files updated. No stale `decode_windows_invalid_chars` or `%ENCODED%` patterns.
- `**json_model_legacy_name` snapshot change**: Instance name changed from `Expected Name` to `Overridden Name` -- correct, this is the intended behavior change (the `Name`/`name` field is now respected instead of ignored).
- **Test fixture migration**: All `%ENCODED%` filenames in `syncback_encoded_names/` correctly replaced with slugified names + meta files.
- **Existing test updates**: All ~12 two-way sync tests with encoded patterns correctly updated to slugified equivalents.
---
## CRITICAL: Model JSON rename loses `.model` compound extension
**File**: [src/change_processor.rs](src/change_processor.rs) lines 786-868
The rename handler's suffix list:
```786:789:src/change_processor.rs
let known_suffixes = [
".server", ".client", ".plugin", ".local",
".legacy",
];
```
Does NOT include `.model`. For `What_Model.model.json5` renamed to `Why?Model`:
- `stem` = `"What_Model.model"`, `extension` = `"json5"`, `script_suffix` = `""` (no match)
- `old_base` = `"What_Model.model"` (includes `.model`)
- New filename = `"Why_Model.json5"` -- `.model` LOST
**Impact**: File type changes from `.model.json5` (JSON instance) to `.json5` (data module). Instance class, properties, hierarchy silently lost. The meta update then writes to `Why_Model.meta.json5` (wrong location for model files). Direct round-trip violation.
**Fix**: Add `.model` to the suffix list. Then detect `.model.json5`/`.model.json` files and update the `name` field INSIDE the model file (like `syncback::meta::upsert_meta_name` but targeting the JSON root's `name` key) instead of writing adjacent `.meta.json5`.
---
## CORRECTNESS: `syncback_update_existing_instance` drops `name` field
**File**: [src/web/api.rs](src/web/api.rs) lines 1091-1098
When updating an existing `.model.json5` via two-way sync (plugin sends updated properties), the code rewrites the entire model file:
```1093:1093:src/web/api.rs
let content = self.serialize_instance_to_model_json(added, None)?;
```
`None` for `instance_name` means the `name` field is NOT written. If the model file previously had `"name": "Hey/Bro"`, the name is silently lost on rewrite. Same issue exists at line 1075 for directory updates via `write_init_meta_json(existing_path, added, None)`.
**Fix**: Compute `meta_name_field` by comparing the slug of the existing filesystem name against `added.name`. If they differ, pass `Some(&added.name)`. This matches the logic in `syncback_instance_to_path_with_stats`.
---
## CORRECTNESS: `remove_instance_at_path` directory case skips grandparent meta
**File**: [src/web/api.rs](src/web/api.rs) lines 1389-1396
When `path.is_dir()` (plain Folder), `remove_dir_all` is called but no adjacent `DirName.meta.json5` at the parent level is cleaned up. The init-file case (lines 1423-1438) correctly handles this.
**Severity**: LOW. Grandparent-level `DirName.meta.json5` only exists from manual creation or the change_processor rename path (which renames it). Standard syncback uses `init.meta.json5` inside the directory.
**Fix**: After `remove_dir_all`, add grandparent meta cleanup matching the init-file path.
---
## TEST COVERAGE GAPS
Prioritized by risk:
1. **Roundtrip test with slugified names** -- `syncback_roundtrip.rs` exists but no fixtures exercise slugified names. None of `meta_name_override`, `model_json_name_override`, `dedup_suffix_with_meta` are in the roundtrip test list.
2. **Model file rename two-way sync** -- No test for renaming a `.model.json5` instance. Would catch the critical bug above.
3. **Syncback idempotency integration** -- Run syncback twice, assert zero changes on second run. Unit tests exist but no full-pipeline test.
4. **3+ instance collision** -- Only 2-instance collision tested (`add_two_colliding_instances_deduplicates`). Need `X/Y`, `X:Y`, `X|Y` -> `X_Y`, `X_Y~1`, `X_Y~2`.
5. **Windows reserved name integration** -- Unit tests for `CON`/`PRN` slugification exist. No build or syncback integration test.
6. **Directory rename with forbidden chars** -- Two-way sync tests cover file renames but not init-file/directory renames.
7. **Case-insensitive collision** -- Code handles it (`.to_lowercase()` everywhere). No integration test.
8. **Meta file empty deletion** -- `rename_slugified_to_clean` test checks meta doesn't have `name` field but doesn't verify the meta file itself is deleted when it becomes empty (the `RemoveNameOutcome::FileDeleted` path).
---
## Summary
| Category | Count | Details |
| ------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Critical bugs | 1 | Model JSON rename loses `.model` extension |
| Correctness | 2 | `syncback_update_existing_instance` drops `name` field; directory removal skips grandparent meta |
| Missing tests | 8 | Roundtrip, model rename, idempotency, 3+ collision, reserved names, dir rename, case collision, meta empty deletion |
| Prior plans | OK | All claims verified in code, no discrepancies |
| Clean areas | 10 | Forward sync, reverse sync, two-way adds/removals, meta lifecycle, legacy cleanup, dedup consistency, schema, snapshots, test fixtures, test updates |

View file

@ -0,0 +1,30 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="DataModel" referent="0">
<Properties>
<string name="Name">reserved_name_override</string>
</Properties>
<Item class="ReplicatedStorage" referent="1">
<Properties>
<string name="Name">ReplicatedStorage</string>
</Properties>
<Item class="ModuleScript" referent="2">
<Properties>
<string name="Name">CON</string>
<string name="Source"><![CDATA[return "I am CON"
]]></string>
</Properties>
</Item>
<Item class="ModuleScript" referent="3">
<Properties>
<string name="Name">PRN</string>
<string name="Source"><![CDATA[return "I am PRN"
]]></string>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View file

@ -0,0 +1,10 @@
{
"name": "reserved_name_override",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"$path": "src"
}
}
}

View file

@ -0,0 +1 @@
return "I am CON"

View file

@ -0,0 +1,3 @@
{
"name": "CON"
}

View file

@ -0,0 +1 @@
return "I am PRN"

View file

@ -0,0 +1,3 @@
{
"name": "PRN"
}

View file

@ -247,6 +247,49 @@ impl JobThreadContext {
}
}
/// Upsert the `name` field inside a `.model.json5` / `.model.json` file,
/// suppressing filesystem events.
fn upsert_model_name_field(&self, model_path: &Path, real_name: &str) {
self.suppress_path(model_path);
if let Err(err) = crate::syncback::meta::upsert_model_name(model_path, real_name) {
self.unsuppress_path(model_path);
log::error!(
"Failed to upsert name in model file {}: {}",
model_path.display(),
err
);
}
}
/// Remove the `name` field from a `.model.json5` / `.model.json` file,
/// suppressing filesystem events.
fn remove_model_name_field(&self, model_path: &Path) {
use crate::syncback::meta::RemoveNameOutcome;
self.suppress_path(model_path);
match crate::syncback::meta::remove_model_name(model_path) {
Ok(RemoveNameOutcome::NoOp) => {
self.unsuppress_path(model_path);
}
Ok(RemoveNameOutcome::FieldRemoved) => {
// File was rewritten — suppress_path already covers it.
}
Ok(RemoveNameOutcome::FileDeleted) => {
// Model files shouldn't be deleted (they have className etc),
// but handle for completeness.
self.unsuppress_path(model_path);
self.suppress_path_remove(model_path);
}
Err(err) => {
self.unsuppress_path(model_path);
log::error!(
"Failed to remove name from model file {}: {}",
model_path.display(),
err
);
}
}
}
/// Remove the `name` field from a `.meta.json5` file, suppressing filesystem
/// events. If the file becomes empty after removal, deletes it entirely.
fn remove_meta_name_field(&self, meta_path: &Path) {
@ -785,7 +828,7 @@ impl JobThreadContext {
.unwrap_or("");
let known_suffixes = [
".server", ".client", ".plugin", ".local",
".legacy",
".legacy", ".model",
];
let script_suffix = known_suffixes
.iter()
@ -912,11 +955,27 @@ impl JobThreadContext {
effective_meta_base = old_base;
}
// Always update the meta name field when the
// Always update the name field when the
// instance name changed, even if the path didn't
// change (e.g. "Foo/Bar" → "Foo|Bar" both slugify
// to "Foo_Bar"). Use deduped_new_name because
// dedup may have appended ~N.
//
// For .model.json5/.model.json files, the name
// field lives INSIDE the model file, not in
// adjacent .meta.json5.
if script_suffix == ".model" {
let model_file = overridden_source_path
.as_deref()
.unwrap_or(path.as_path());
if deduped_new_name != *new_name {
self.upsert_model_name_field(
model_file, new_name,
);
} else {
self.remove_model_name_field(model_file);
}
} else {
let current_meta = parent.join(format!(
"{}.meta.json5",
effective_meta_base
@ -932,6 +991,7 @@ impl JobThreadContext {
}
}
}
}
InstigatingSource::ProjectNode { .. } => {
log::warn!(
"Cannot rename instance {:?} — defined in project file",

View file

@ -323,7 +323,9 @@ pub fn validate_file_name<S: AsRef<str>>(name: S) -> anyhow::Result<()> {
/// Known script suffixes that appear between the base name and file extension.
/// For example, in `MyScript.server.luau`, `.server` is the suffix.
const KNOWN_SCRIPT_SUFFIXES: &[&str] = &[".server", ".client", ".plugin", ".local", ".legacy"];
const KNOWN_SCRIPT_SUFFIXES: &[&str] = &[
".server", ".client", ".plugin", ".local", ".legacy", ".model",
];
/// Strips a known script suffix from a file stem.
///

View file

@ -46,6 +46,50 @@ pub enum RemoveNameOutcome {
FileDeleted,
}
/// Upsert the `name` field inside a `.model.json5` / `.model.json` file.
///
/// Parses the existing JSON, sets/replaces the `name` key, and rewrites.
/// Unlike `upsert_meta_name`, this modifies the model file in-place.
pub fn upsert_model_name(model_path: &Path, real_name: &str) -> anyhow::Result<()> {
let bytes = fs::read(model_path)?;
let mut obj = match crate::json::from_slice::<serde_json::Value>(&bytes) {
Ok(serde_json::Value::Object(map)) => map,
_ => anyhow::bail!("model file is not a JSON object: {}", model_path.display()),
};
obj.insert(
"name".to_string(),
serde_json::Value::String(real_name.to_string()),
);
let content = crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj))?;
fs::write(model_path, &content)?;
Ok(())
}
/// Remove the `name` field from a `.model.json5` / `.model.json` file.
///
/// Unlike meta files, model files are never deleted when they become "empty"
/// (they always have at least `className`). Returns `RemoveNameOutcome` for
/// consistency with the meta helpers.
pub fn remove_model_name(model_path: &Path) -> anyhow::Result<RemoveNameOutcome> {
if !model_path.exists() {
return Ok(RemoveNameOutcome::NoOp);
}
let bytes = match fs::read(model_path) {
Ok(b) => b,
Err(_) => return Ok(RemoveNameOutcome::NoOp),
};
let mut obj = match crate::json::from_slice::<serde_json::Value>(&bytes) {
Ok(serde_json::Value::Object(map)) => map,
_ => return Ok(RemoveNameOutcome::NoOp),
};
if obj.remove("name").is_none() {
return Ok(RemoveNameOutcome::NoOp);
}
let content = crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj))?;
fs::write(model_path, &content)?;
Ok(RemoveNameOutcome::FieldRemoved)
}
/// Remove the `name` field from a `.meta.json5` file.
///
/// If the file becomes an empty object after removal, deletes it entirely.

View file

@ -1072,7 +1072,18 @@ impl ApiService {
if existing_path.is_dir() {
// Update init.meta.json5 if needed
if !added.properties.is_empty() {
self.write_init_meta_json(existing_path, added, None)?;
// Preserve the name field if the directory slug differs
// from the instance name (due to slugification/dedup).
let dir_name = existing_path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("");
let instance_name = if dir_name != added.name {
Some(added.name.as_str())
} else {
None
};
self.write_init_meta_json(existing_path, added, instance_name)?;
log::info!(
"Syncback: Updated existing {} at {}/init.meta.json5",
class_name,
@ -1090,7 +1101,19 @@ impl ApiService {
}
} else {
// It's a standalone file (e.g., .model.json5)
let content = self.serialize_instance_to_model_json(added, None)?;
// Preserve the name field if the filesystem slug differs
// from the instance name (due to slugification/dedup).
let stem = existing_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let base = crate::syncback::strip_script_suffix(stem);
let instance_name = if base != added.name {
Some(added.name.as_str())
} else {
None
};
let content = self.serialize_instance_to_model_json(added, instance_name)?;
self.suppress_path(existing_path);
fs::write(existing_path, &content).with_context(|| {
format!("Failed to write file: {}", existing_path.display())
@ -1393,6 +1416,26 @@ impl ApiService {
return false;
}
log::info!("Syncback: Removed directory at {}", path.display());
// Also remove adjacent dir-level meta in the parent if it
// exists (e.g. parent/DirName.meta.json5), matching the
// init-file case below.
if let Some(parent) = path.parent() {
if let Some(dir_name) = path.file_name().and_then(|f| f.to_str()) {
let dir_meta = parent.join(format!("{}.meta.json5", dir_name));
if dir_meta.exists() {
self.suppress_path_remove(&dir_meta);
if let Err(err) = fs::remove_file(&dir_meta) {
log::warn!(
"Failed to remove dir-level meta file {}: {}",
dir_meta.display(),
err
);
}
}
}
}
return true;
}

View file

@ -69,6 +69,7 @@ gen_build_tests! {
model_json_name_override,
dedup_suffix_with_meta,
init_meta_name_override,
reserved_name_override,
}
fn run_build_test(test_name: &str) {

View file

@ -8,6 +8,7 @@
//! Produces semantically identical output. This validates that the syncback
//! system correctly reconstructs project structure from binary place files.
use std::collections::HashMap;
use std::{fs, path::Path};
use rbx_dom_weak::{ustr, WeakDom};
@ -284,6 +285,11 @@ roundtrip_tests! {
// Issue regression tests
issue_546,
// Slugified names and metadata overrides
meta_name_override,
model_json_name_override,
dedup_suffix_with_meta,
}
// =============================================================================
@ -337,3 +343,111 @@ fn infer_service_name() {
fn rbxmx_ref() {
run_roundtrip_test("rbxmx_ref");
}
// =============================================================================
// SYNCBACK IDEMPOTENCY TEST
// =============================================================================
/// Build -> syncback -> syncback again. The second syncback must produce
/// zero filesystem changes (i.e., the result is identical to the first).
/// This validates that syncback output is stable and self-consistent.
#[test]
fn syncback_idempotency() {
let _ = env_logger::try_init();
// Use dedup_suffix_with_meta as it exercises slugified names + meta
let project_path = Path::new(BUILD_TESTS_PATH).join("dedup_suffix_with_meta");
// 1. Build original -> rbxm
let (_tmp1, original_rbxm) = run_rojo_build(&project_path, "original.rbxm");
// 2. First syncback
let first_dir = tempdir().expect("Failed to create temp dir");
copy_project_dir(&project_path, first_dir.path());
assert!(
run_rojo_syncback_clean(first_dir.path(), &original_rbxm),
"First syncback should succeed"
);
ensure_project_dirs_exist(first_dir.path());
// Snapshot all files after first syncback
let first_snapshot = snapshot_dir(first_dir.path());
// 3. Build from first syncback result -> rbxm
let (_tmp2, first_rbxm) = run_rojo_build(first_dir.path(), "first.rbxm");
// 4. Second syncback (into a copy of the first syncback result)
let second_dir = tempdir().expect("Failed to create temp dir");
copy_project_dir(first_dir.path(), second_dir.path());
assert!(
run_rojo_syncback_clean(second_dir.path(), &first_rbxm),
"Second syncback should succeed"
);
ensure_project_dirs_exist(second_dir.path());
// 5. Snapshot all files after second syncback
let second_snapshot = snapshot_dir(second_dir.path());
// 6. Compare snapshots — must be identical
assert_eq!(
first_snapshot.len(),
second_snapshot.len(),
"File count should be identical between first and second syncback.\n\
First: {:?}\nSecond: {:?}",
first_snapshot.keys().collect::<Vec<_>>(),
second_snapshot.keys().collect::<Vec<_>>(),
);
for (path, first_content) in &first_snapshot {
let second_content = second_snapshot.get(path).unwrap_or_else(|| {
panic!(
"File {:?} exists after first syncback but not after second",
path
)
});
assert_eq!(
first_content, second_content,
"File {:?} changed between first and second syncback",
path
);
}
}
/// Snapshot all files under a directory into a map of relative_path -> contents.
fn snapshot_dir(root: &Path) -> HashMap<String, Vec<u8>> {
let mut result = HashMap::new();
snapshot_dir_inner(root, root, &mut result);
result
}
fn snapshot_dir_inner(root: &Path, current: &Path, map: &mut HashMap<String, Vec<u8>>) {
if let Ok(entries) = fs::read_dir(current) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
snapshot_dir_inner(root, &path, map);
} else {
let relative = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if let Ok(content) = fs::read(&path) {
map.insert(relative, content);
}
}
}
}
}
// =============================================================================
// WINDOWS RESERVED NAME ROUNDTRIP TEST
// =============================================================================
/// Instances named after Windows reserved names (CON, PRN) should round-trip
/// correctly: slugified on disk, real name in meta, rebuild produces correct
/// instance names.
#[test]
fn reserved_name_roundtrip() {
run_roundtrip_test("reserved_name_override");
}

View file

@ -5197,7 +5197,8 @@ fn rename_clean_to_slugified() {
}
/// Renaming a slugified instance back to a clean name should remove the
/// meta `name` field (or the entire meta file if it had no other fields).
/// meta `name` field. When the meta file had no other fields, the file
/// itself must be deleted (the `RemoveNameOutcome::FileDeleted` path).
#[test]
fn rename_slugified_to_clean() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
@ -5231,15 +5232,360 @@ fn rename_slugified_to_clean() {
"What_Module.meta.json5 should be gone after rename",
);
// The new meta file should either not exist (clean name needs no
// name override) or, if it exists, should NOT contain a "name" field.
if new_meta.exists() {
let meta_content = fs::read_to_string(&new_meta).unwrap();
// The meta file had only a "name" field, so it should be deleted
// entirely (not just emptied). This validates the FileDeleted path.
poll_not_exists(
&new_meta,
"CleanName.meta.json5 should NOT exist (name-only meta → deleted)",
);
});
}
// ---------------------------------------------------------------------------
// Tests: Model JSON rename (compound extension preservation)
// ---------------------------------------------------------------------------
/// Renaming a .model.json5 instance must preserve the `.model` compound
/// extension and update the `name` field inside the model file (not in
/// adjacent .meta.json5).
#[test]
fn rename_model_json_preserves_compound_extension() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
let (session_id, model_id) = get_encoded_names_instance(&session, "What?Model");
let src = session.path().join("src");
let old_path = src.join("What_Model.model.json5");
assert_file_exists(&old_path, "What_Model.model.json5 before rename");
// Rename to another name needing slugification
send_update(
&session,
&session_id,
InstanceUpdate {
id: model_id,
changed_name: Some("Why?Model".to_string()),
changed_class_name: None,
changed_properties: UstrMap::default(),
changed_metadata: None,
},
);
// The .model compound extension MUST be preserved
let new_path = src.join("Why_Model.model.json5");
poll_file_exists(&new_path, "Why_Model.model.json5 should exist after rename");
poll_not_exists(
&old_path,
"What_Model.model.json5 should be gone after rename",
);
// The WRONG path (lost .model extension) must NOT exist
let wrong_path = src.join("Why_Model.json5");
assert_not_exists(
&wrong_path,
"Why_Model.json5 (without .model) must NOT exist",
);
// The name field must be INSIDE the model file, not in adjacent meta.
// JSON5 serialization uses unquoted keys, so check for key without
// surrounding double-quotes.
let model_content = fs::read_to_string(&new_path).unwrap();
assert!(
!meta_content.contains("\"name\""),
"CleanName.meta.json5 should not have a \"name\" field, got: {}",
meta_content
model_content.contains("name") && model_content.contains("Why?Model"),
"Model file should contain a name field with \"Why?Model\", got: {}",
model_content
);
// No adjacent .meta.json5 should be created for model files
let wrong_meta = src.join("Why_Model.meta.json5");
assert_not_exists(
&wrong_meta,
"Adjacent .meta.json5 should NOT be created for model files",
);
});
}
/// Renaming a .model.json5 instance to a clean name should remove the
/// `name` field from inside the model file.
#[test]
fn rename_model_json_to_clean_name() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
let (session_id, model_id) = get_encoded_names_instance(&session, "What?Model");
let src = session.path().join("src");
let old_path = src.join("What_Model.model.json5");
assert_file_exists(&old_path, "What_Model.model.json5 before rename");
send_update(
&session,
&session_id,
InstanceUpdate {
id: model_id,
changed_name: Some("CleanModel".to_string()),
changed_class_name: None,
changed_properties: UstrMap::default(),
changed_metadata: None,
},
);
let new_path = src.join("CleanModel.model.json5");
poll_file_exists(
&new_path,
"CleanModel.model.json5 should exist after rename",
);
poll_not_exists(&old_path, "What_Model.model.json5 should be gone");
// The model file should NOT have a "name" key when the
// filesystem name matches the instance name.
// JSON5 uses unquoted keys (`name:` not `"name":`), so we
// check for the key pattern at line start.
let model_content = fs::read_to_string(&new_path).unwrap();
let has_name_key = model_content
.lines()
.any(|l| l.trim().starts_with("name:") || l.trim().starts_with("\"name\":"));
assert!(
!has_name_key,
"CleanModel.model.json5 should not have a name key, got: {}",
model_content
);
let has_classname = model_content
.lines()
.any(|l| l.trim().starts_with("className:") || l.trim().starts_with("\"className\":"));
assert!(
has_classname,
"Model file should still have className, got: {}",
model_content
);
});
}
// ---------------------------------------------------------------------------
// Tests: 3+ instance collision deduplication
// ---------------------------------------------------------------------------
/// Adding 3 instances that all slug to the same name (X/Y, X:Y, X|Y → X_Y)
/// should produce X_Y.luau, X_Y~1.luau, X_Y~2.luau with correct meta files.
#[test]
fn add_three_colliding_instances_deduplicates() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let root_read = session.get_api_read(info.root_instance_id).unwrap();
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
let names = ["X/Y", "X:Y", "X|Y"];
let mut added_map = HashMap::new();
for (i, name) in names.iter().enumerate() {
let mut props = HashMap::new();
props.insert(
"Source".to_string(),
Variant::String(format!("-- instance {}", i + 1)),
);
added_map.insert(
Ref::new(),
AddedInstance {
parent: Some(rs_id),
name: name.to_string(),
class_name: "ModuleScript".to_string(),
properties: props,
children: vec![],
},
);
}
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: added_map,
updated: vec![],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(500));
let src = session.path().join("src");
let base = src.join("X_Y.luau");
let dedup1 = src.join("X_Y~1.luau");
let dedup2 = src.join("X_Y~2.luau");
assert!(
base.exists() && dedup1.exists() && dedup2.exists(),
"All three slugified files should exist.\n\
X_Y.luau: {}, X_Y~1.luau: {}, X_Y~2.luau: {}",
base.exists(),
dedup1.exists(),
dedup2.exists()
);
// All three should have meta files with the original names
let base_meta = src.join("X_Y.meta.json5");
let dedup1_meta = src.join("X_Y~1.meta.json5");
let dedup2_meta = src.join("X_Y~2.meta.json5");
assert_file_exists(&base_meta, "Meta for base slug");
assert_file_exists(&dedup1_meta, "Meta for ~1 dedup");
assert_file_exists(&dedup2_meta, "Meta for ~2 dedup");
// Each meta should contain one of the original names
let all_metas = [
fs::read_to_string(&base_meta).unwrap(),
fs::read_to_string(&dedup1_meta).unwrap(),
fs::read_to_string(&dedup2_meta).unwrap(),
];
for meta in &all_metas {
let has_name =
meta.contains("\"X/Y\"") || meta.contains("\"X:Y\"") || meta.contains("\"X|Y\"");
assert!(
has_name,
"Meta should contain one of X/Y, X:Y, X|Y, got: {}",
meta
);
}
});
}
// ---------------------------------------------------------------------------
// Tests: Directory rename with forbidden chars
// ---------------------------------------------------------------------------
/// Renaming a directory-format script to a name with forbidden chars should
/// rename the directory using the slugified name and write a meta file with
/// the real name.
#[test]
fn rename_directory_format_to_slugified_name() {
run_serve_test("syncback_format_transitions", |session, _redactions| {
let (session_id, dir_module_id) =
get_format_transitions_instance(&session, "DirModuleWithChildren");
let src = session.path().join("src");
let old_dir = src.join("DirModuleWithChildren");
assert!(old_dir.is_dir(), "Old directory should exist before rename");
// Rename to a name with forbidden chars
send_update(
&session,
&session_id,
InstanceUpdate {
id: dir_module_id,
changed_name: Some("Dir/With:Chars".to_string()),
changed_class_name: None,
changed_properties: UstrMap::default(),
changed_metadata: None,
},
);
let new_dir = src.join("Dir_With_Chars");
poll_not_exists(&old_dir, "Old directory should be gone after rename");
// Poll for the new directory
let start = Instant::now();
loop {
if new_dir.is_dir() {
break;
}
if start.elapsed() > Duration::from_millis(API_POLL_TIMEOUT_MS) {
panic!(
"New directory Dir_With_Chars should exist after rename (timed out after {}ms)",
API_POLL_TIMEOUT_MS
);
}
thread::sleep(Duration::from_millis(50));
}
// init.luau should be inside the renamed directory
let init_file = new_dir.join("init.luau");
assert_file_exists(&init_file, "init.luau inside renamed directory");
// Children should be present in the renamed directory
let child_a = new_dir.join("ChildA.luau");
assert_file_exists(&child_a, "ChildA.luau inside renamed directory");
// For init-file directories, the name is stored inside the
// directory in init.meta.json5, not adjacent to it.
let init_meta = new_dir.join("init.meta.json5");
poll_file_exists(
&init_meta,
"init.meta.json5 inside Dir_With_Chars should exist with name override",
);
let meta_content = fs::read_to_string(&init_meta).unwrap();
assert!(
meta_content.contains("Dir/With:Chars"),
"init.meta.json5 should contain real name \"Dir/With:Chars\", got: {}",
meta_content
);
});
}
// ---------------------------------------------------------------------------
// Tests: Case-insensitive collision
// ---------------------------------------------------------------------------
/// Adding two instances whose names differ only in case (Foo and foo) should
/// be deduplicated, since they slug to the same lowercase key.
#[test]
fn add_case_insensitive_colliding_instances() {
run_serve_test("syncback_encoded_names", |session, _redactions| {
let info = session.get_api_rojo().unwrap();
let root_read = session.get_api_read(info.root_instance_id).unwrap();
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
let mut added_map = HashMap::new();
let mut props1 = HashMap::new();
props1.insert(
"Source".to_string(),
Variant::String("-- upper".to_string()),
);
added_map.insert(
Ref::new(),
AddedInstance {
parent: Some(rs_id),
name: "CaseTest".to_string(),
class_name: "ModuleScript".to_string(),
properties: props1,
children: vec![],
},
);
let mut props2 = HashMap::new();
props2.insert(
"Source".to_string(),
Variant::String("-- lower".to_string()),
);
added_map.insert(
Ref::new(),
AddedInstance {
parent: Some(rs_id),
name: "casetest".to_string(),
class_name: "ModuleScript".to_string(),
properties: props2,
children: vec![],
},
);
let write_request = WriteRequest {
session_id: info.session_id,
removed: vec![],
added: added_map,
updated: vec![],
};
session.post_api_write(&write_request).unwrap();
thread::sleep(Duration::from_millis(500));
let src = session.path().join("src");
// One should get the base name, the other should get ~1
// The exact casing depends on processing order, but both files
// must exist with distinct names.
let base = src.join("CaseTest.luau");
let base_lower = src.join("casetest.luau");
let dedup = src.join("CaseTest~1.luau");
let dedup_lower = src.join("casetest~1.luau");
let base_exists = base.exists() || base_lower.exists();
let dedup_exists = dedup.exists() || dedup_lower.exists();
assert!(
base_exists && dedup_exists,
"Both case-insensitive colliding instances should exist as separate files.\n\
CaseTest.luau: {}, casetest.luau: {}, CaseTest~1.luau: {}, casetest~1.luau: {}",
base.exists(),
base_lower.exists(),
dedup.exists(),
dedup_lower.exists()
);
});
}