diff --git a/.cursor/commands/ci.md b/.cursor/commands/ci.md index 62e238ee..e3f09e0f 100644 --- a/.cursor/commands/ci.md +++ b/.cursor/commands/ci.md @@ -1,185 +1,45 @@ # /ci - Complete CI Pipeline -Run the complete CI pipeline: auto-fix all formatting, run all linters, build everything, and run ALL tests. +Run the complete CI pipeline by executing the appropriate platform script. ## Instructions -- If the user's message contains **"rbx-dom"**, run **both** the Rojo and rbx-dom sections below. -- Otherwise, **skip the entire rbx-dom section** (steps 9–14) to save time. +### 1. Run the CI Script -Execute ALL applicable steps in order. Fix issues automatically where possible. +**Always run the full suite including rbx-dom. No exceptions. No flags.** ---- - -## Rojo - -### 1. Auto-fix Lua Formatting (Stylua) +**Windows (PowerShell):** ```powershell -stylua plugin/src +.\scripts\ci.ps1 ``` -### 2. Auto-fix Rust Formatting -```powershell -cargo fmt +**macOS/Linux (Bash):** +```bash +bash scripts/ci.sh ``` -### 3. Auto-fix TypeScript Formatting (Prettier) for vscode-rojo -```powershell -cd vscode-rojo -npx prettier --write "src/**/*.ts" -cd .. -``` +**Monitor the script output as it runs.** After each step completes, check its result. If a step failed, kill the script and switch to manual fixing (step 2) — don't let it continue to the next step. -### 4. Lua Static Analysis (Selene) - MUST PASS WITH ZERO ISSUES -```powershell -selene plugin/src -``` +### 2. Fix Failures Between Steps -**CRITICAL: Selene must exit with code 0 (zero errors AND zero warnings).** Warnings count as failures. +When a step fails, stop the script and fix the issue before continuing. Use targeted commands to speed up the feedback loop: -If Selene reports ANY errors or warnings: -1. **FIX THEM IMMEDIATELY** before proceeding - do not just report them -2. Common fixes: - - `unused_variable`: Replace with `_` (e.g., `for _ = 1, 10 do`) or remove the variable entirely - - `unused_variable` for imports: Remove the unused require/import line -3. Re-run Selene after fixes to confirm exit code 0 -4. Only proceed once Selene shows: `Results: 0 errors, 0 warnings, 0 parse errors` +1. **Formatting/linting failures** — auto-fix with the relevant formatter (`cargo fmt`, `stylua`), then verify with the specific check command only (e.g. `cargo fmt --check`). -### 5. Rust Linting (Clippy) - Auto-fix where possible -```powershell -cargo clippy --fix --allow-dirty --allow-staged -``` -Then verify with: -```powershell -cargo clippy --all-targets --all-features -``` -If there are remaining warnings/errors, fix them before proceeding. +2. **Test failures — investigate the implementation, not just the tests.** For every failing test: + - Read the failing test to understand what behavior it asserts. + - Read the implementation code the test exercises. + - Determine the root cause: + - **Implementation regression/bug:** The feature is incomplete or broken. **Fix the implementation** so the test passes. Do not weaken or delete the test. + - **Outdated test fixture:** The test's snapshot or fixture data is stale because of an intentional change. Update the fixture with `cargo insta review`/`cargo insta accept`, or regenerate the fixture data. Confirm the new fixture reflects correct behavior. + - **Incomplete feature:** A new feature was added but doesn't fully handle all cases the tests cover. **Complete the feature implementation** rather than trimming the tests to match a partial implementation. + - **Never** delete or gut a test just to make CI green. Tests exist for a reason — the code must satisfy them, not the other way around. + - **Re-run only the failing test(s)** to verify fixes quickly (e.g. `cargo test `, `cargo test -p `). Do not re-run the entire suite until you believe all issues are resolved. -### 6. Build Everything -```powershell -cargo build --locked --all-targets --all-features -``` +3. **After all known issues are fixed**, re-run the full CI script from step 1 to catch any remaining failures. -### 7. Run ALL Rust Tests -```powershell -cargo test --locked --all-targets --all-features -``` +4. Repeat until the script exits with `Overall: PASS`. -### 8. Run Roblox Plugin Tests +### 3. Report -**Do NOT use the scripts (`unit-test-plugin.ps1` / `unit-test-plugin.sh`).** They reference a system-installed binary which may not exist. Always use the freshly built binary from step 6. - -```powershell -.\target\debug\atlas.exe build plugin/test-place.project.json -o TestPlace.rbxl -run-in-roblox --script plugin/run-tests.server.lua --place TestPlace.rbxl -``` - ---- - -## rbx-dom (submodule) - -All rbx-dom steps run inside the `rbx-dom/` directory. - -### 9. Auto-fix Lua Formatting (Stylua) for rbx_dom_lua -```powershell -cd rbx-dom -stylua rbx_dom_lua/src -cd .. -``` - -### 10. Auto-fix Rust Formatting -```powershell -cd rbx-dom -cargo fmt -cd .. -``` - -### 11. Lua Static Analysis (Selene) for rbx_dom_lua - MUST PASS WITH ZERO ISSUES -```powershell -cd rbx-dom -selene rbx_dom_lua/src -cd .. -``` - -Same rules as step 4: exit code 0 required, fix all errors and warnings before proceeding. - -### 12. Rust Linting (Clippy) - Auto-fix where possible -```powershell -cd rbx-dom -cargo clippy --fix --allow-dirty --allow-staged -``` -Then verify with: -```powershell -cd rbx-dom -cargo clippy --all-targets --all-features -- -D warnings -cd .. -``` -If there are remaining warnings/errors, fix them before proceeding. - -### 13. Build rbx-dom -```powershell -cd rbx-dom -cargo build --verbose -cargo build --all-features --verbose -cd .. -``` - -### 14. Run ALL rbx-dom Rust Tests -```powershell -cd rbx-dom -cargo test --verbose -cargo test --all-features --verbose -cd .. -``` - ---- - -## Reporting - -After ALL steps complete, provide a summary: - -``` -=== CI COMPLETE === - ---- Rojo --- - -Formatting: - - Stylua: [fixed X files / no changes needed] - - Rustfmt: [fixed X files / no changes needed] - - Prettier (vscode-rojo): [fixed X files / no changes needed] - -Linting: - - Selene: PASS (0 errors, 0 warnings) / FAIL (X errors, Y warnings) - - Clippy: PASS / FAIL (X warnings) - -Build: PASS / FAIL - -Tests: - - Rust: X passed, Y failed - - Plugin: X passed, Y failed - ---- rbx-dom (only if included) --- - -Formatting: - - Stylua (rbx_dom_lua): [fixed X files / no changes needed] - - Rustfmt: [fixed X files / no changes needed] - -Linting: - - Selene (rbx_dom_lua): PASS (0 errors, 0 warnings) / FAIL (X errors, Y warnings) - - Clippy: PASS / FAIL (X warnings) - -Build: PASS / FAIL -Build (all features): PASS / FAIL - -Tests: - - Rust: X passed, Y failed - - Rust (all features): X passed, Y failed - ---- Overall: PASS / FAIL --- -``` - -**IMPORTANT:** -- Selene PASS requires exit code 0 (0 errors AND 0 warnings). Any warnings = FAIL. -- If any step fails, continue running the remaining steps to get a complete picture, then report all failures at the end. -- Do not report "Overall: PASS" unless ALL linters pass with zero issues across all sections that were run. -- If rbx-dom was skipped, omit its section from the report entirely. +Once CI passes clean, provide a brief summary of what was fixed (if anything) and confirm the final result. diff --git a/.cursor/plans/audit_slugify_branch_e913b7dc.plan.md b/.cursor/plans/audit_slugify_branch_e913b7dc.plan.md new file mode 100644 index 00000000..8a4c8fb1 --- /dev/null +++ b/.cursor/plans/audit_slugify_branch_e913b7dc.plan.md @@ -0,0 +1,298 @@ +--- +name: Audit slugify branch +overview: "Production-grade audit of the `slugify` branch. The invariant: filesystem must be a perfect 1:1 representation of the Studio place such that syncback → rebuild from filesystem produces a byte-identical place file. Every audit item is evaluated against this round-trip fidelity standard." +todos: + - id: round-trip-invariant + content: "Verify the core invariant: for every instance in a place, syncback writes files that forward-sync rebuilds into a bit-identical instance tree (name, class, properties, hierarchy)" + status: completed + - id: forward-sync + content: "Audit forward sync: all middleware correctly resolves instance names from meta name field or filename stem, no lingering decode_path_name" + status: completed + - id: reverse-sync + content: "Audit reverse sync: slugify + dedup + meta emission correct in file_names.rs, snapshot.rs, dir.rs, project.rs" + status: completed + - id: two-way-sync + content: "Audit two-way sync: change_processor.rs renames and api.rs adds/removes handle slugify, dedup, meta lifecycle, event suppression" + status: completed + - id: dedup-consistency + content: "Verify dedup_key consistency: case sensitivity, cross-format collisions, old-inst vs new-inst paths, all insertion sites" + status: completed + - id: property-preservation + content: Verify no property data is lost or mangled through name changes -- Name property, metadata fields, ref properties, attributes all survive round-trips + status: completed + - id: config-cleanup + content: Grep for any lingering path_encoding, encode/decode references, %ENCODED% patterns, removed config fields + status: completed + - id: test-coverage + content: Catalog existing tests vs missing tests -- especially round-trip tests that assert filesystem→rebuild identity + status: completed + - id: json-model-name + content: "Audit json_model name field: read path, write path, legacy compat, json_model_legacy_name snapshot change correctness" + status: completed + - id: meta-lifecycle + content: "Audit meta file lifecycle: creation, update, deletion, merge with existing fields, orphaned meta handling" + status: completed + - id: legacy-cleanup + content: "Verify legacy %ENCODING% system is fully purged: no references in code/tests/fixtures, old config fields parse without crashing" + status: completed + - id: write-report + content: "Produce final report: critical issues, correctness concerns, missing test coverage, cleanup items with file paths and suggested fixes" + status: completed +isProject: false +--- + +# Audit: slugify Branch -- Production-Grade Round-Trip Fidelity + +## Quality Standard + +This is a **production-grade synchronization system**. The filesystem must represent a **1:1 copy** of the Studio place. The core invariant: + +> **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** -- same names, same classes, same properties, same hierarchy, same ref targets. Any deviation is a bug. + +Every audit item below must be evaluated against this standard. A "works most of the time" finding is not acceptable. If there is ANY code path where an instance name, property, or hierarchy relationship can be lost, mangled, or silently altered through a syncback→rebuild cycle, flag it as **critical**. + +## Code Quality Standard + +We are building a **maintainable** codebase. During the audit, watch for duplicated logic -- the same slugify/dedup/meta-update pattern copy-pasted across `change_processor.rs`, `api.rs`, `dir.rs`, `project.rs`, etc. If you find duplicated code that could be consolidated into a shared helper: + +- **Small refactor** (extracting a helper function, consolidating 2-3 call sites): flag it AND fix it as part of this audit. +- **Major system rewrite** (restructuring data flow, changing function signatures across 10+ call sites, redesigning the snapshot pipeline): flag it clearly in a **"Deferred Refactors"** section at the bottom of the report. We will evaluate whether it's worth doing after the audit is complete. + +The goal is clean, DRY code that is easy to reason about and hard to get wrong. If the same "compute meta path, check if name needs slugify, upsert/remove name field" sequence appears in 3+ places, that's a refactor candidate. + +## Context + +This audit covers two plans that drove the branch: + +- [nuke_path_encoding_migration](.cursor/plans/nuke_path_encoding_migration_7dfffc4d.plan.md) -- removed `src/path_encoding.rs`, replaced all encode/decode callsites with `slugify_name()` + `deduplicate_name()` + metadata `name` field +- [fix_stem-level_dedup](.cursor/plans/fix_stem-level_dedup_8fa9a015.plan.md) -- fixed dedup key propagation so `taken_names` contains bare slugs (not filenames with extensions) + +Run `git diff main...HEAD` for the full diff. Read both plan files in full. Then audit every area below, citing file paths, line numbers, and code snippets. Flag anything missing, incorrect, inconsistent, or undertested. + +--- + +## 1. Round-Trip Identity Verification + +This is the single most important audit area. Trace the **complete lifecycle** of an instance with a name containing forbidden chars through every path: + +### 1a. Syncback → Forward Sync Round-Trip + +Pick a concrete example: instance named `"Key:Script"` (ServerScript class). + +- **Syncback writes**: `Key_Script.server.luau` + `Key_Script.meta.json5` with `{"name": "Key:Script"}` +- **Forward sync reads**: `Key_Script.server.luau` → detects adjacent `Key_Script.meta.json5` → reads `name` field → sets `specified_name` on metadata → final instance name becomes `"Key:Script"` +- **Verify**: Trace this exact path through the code. Is there ANY point where the name could become `"Key_Script"` instead of `"Key:Script"`? Any middleware that reads the filename stem and ignores the meta name? + +### 1b. Two-Way Sync → Forward Sync Round-Trip + +Instance added in Studio with name `"What?Module"` during a live `rojo serve` session: + +- **API handler writes**: `What_Module.luau` + `What_Module.meta.json5` with `{"name": "What?Module"}` +- **Change processor** picks up the new files → snapshots them → applies to DOM +- **Verify**: Does the DOM instance end up with name `"What?Module"` or `"What_Module"`? If the change processor snapshots the new file before the meta file is written, what happens? + +### 1c. Rename Round-Trip + +Instance renamed in Studio from `"Clean"` to `"Has/Slash"`: + +- Old file `Clean.server.luau` is removed +- New file `Has_Slash.server.luau` + `Has_Slash.meta.json5` with `{"name": "Has/Slash"}` is created +- **Verify**: Is there a window where the old file is deleted but the new file hasn't been written yet? Could the file watcher fire in between and produce an incorrect intermediate state? + +### 1d. Collision Round-Trip + +Two sibling instances: `"A/B"` (ModuleScript) and `"A_B"` (ModuleScript). Both slugify to `A_B`. + +- **Syncback**: First gets `A_B.luau`, second gets `A_B~1.luau` (with meta `{"name": "A_B"}` since slug+dedup differs from real name) +- **Forward sync rebuild**: `A_B.luau` → name `"A/B"` (from meta), `A_B~1.luau` → name `"A_B"` (from meta) +- **Verify**: Is the ordering deterministic? If processed in reverse order next time, do names get swapped? Is there a stable sort or tie-breaking rule? + +### 1e. Directory Instance Round-Trip + +Folder named `"My:Folder"`: + +- **Syncback**: Directory `My_Folder/` + `My_Folder/init.meta.json5` with `{"name": "My:Folder"}` +- **Forward sync**: Reads directory → finds `init.meta.json5` → applies name override → instance named `"My:Folder"` +- **Verify**: Does `DirectoryMetadata.apply_name()` actually work? Is `init.meta.json5` read before or after the directory name is set? + +### 1f. Model JSON Round-Trip + +Configuration instance named `"What?Model"`: + +- **Syncback**: `What_Model.model.json5` with `{"name": "What?Model", "className": "Configuration"}` +- **Forward sync**: Reads `.model.json5` → parses `name` field → uses it as instance name +- **Verify**: Trace through [json_model.rs](src/snapshot_middleware/json_model.rs). Does the `name` field actually override? Is there a priority conflict with `properties.Name`? + +--- + +## 2. Forward Sync Completeness (filesystem → Roblox) + +Every middleware that reads files and produces `InstanceSnapshot` must correctly resolve the instance name. + +- [src/snapshot_middleware/mod.rs](src/snapshot_middleware/mod.rs): Is `decode_path_name` fully removed? Does every middleware path derive the instance name from the metadata `name` field (when present) or the filename stem (when absent)? +- [src/snapshot_middleware/meta_file.rs](src/snapshot_middleware/meta_file.rs): Do `AdjacentMetadata` and `DirectoryMetadata` both have `name: Option`? Do `apply_all` / `apply_name` correctly set `specified_name` on `InstanceMetadata`? Is `is_empty()` updated for `name`? +- [src/snapshot_middleware/json_model.rs](src/snapshot_middleware/json_model.rs): Does `name` from `.model.json5` actually override the filename-derived name? Trace parse → `InstanceSnapshot` construction → final instance name. Any path where `name` is silently ignored? +- [src/snapshot_middleware/lua.rs](src/snapshot_middleware/lua.rs), [txt.rs](src/snapshot_middleware/txt.rs), [csv.rs](src/snapshot_middleware/csv.rs): Correct instance names? Any lingering `encode_path_name`/`decode_path_name` references? +- **Edge cases**: + - `Foo~1.server.luau` with NO meta file -- must produce instance name `Foo~1` (backward compat, tilde is NOT a dedup marker during reads) + - `Foo~1.server.luau` WITH meta `"name": "Foo/Bar"` -- meta name must win + - `init.meta.json5` with `"name": "SomeOverride"` inside directory `MyFolder` -- must name the directory instance `SomeOverride` + +--- + +## 3. Reverse Sync Completeness (Roblox → filesystem / syncback) + +Every path that writes instances to the filesystem must use slugify + dedup + metadata correctly, and the written files must round-trip back to identical instances. + +- [src/syncback/file_names.rs](src/syncback/file_names.rs): Audit `slugify_name()` for all 9 forbidden chars (`< > : " / \ | ? *`), Windows reserved names (CON, PRN, NUL, COM1-9, LPT1-9), trailing dots/spaces, empty strings, all-forbidden-chars strings. Does `deduplicate_name()` handle `~1` already taken (skip to `~2`)? Does `name_for_inst()` correctly return `(filename, needs_meta, dedup_key)` for both old-inst and new-inst paths? +- [src/syncback/snapshot.rs](src/syncback/snapshot.rs): Do `with_joined_path` and `with_base_path` propagate `dedup_key`? Are callers in dir.rs/project.rs using `dedup_key` (not `filename`) when inserting into `taken_names`? +- [src/snapshot_middleware/dir.rs](src/snapshot_middleware/dir.rs): Is `taken_names` seeded correctly (tree children, not disk)? Is `dedup_key.to_lowercase()` inserted after each child? Any ordering issue where a child is processed before its collision partner is in the set? +- [src/snapshot_middleware/project.rs](src/snapshot_middleware/project.rs): Same checks. Verify `or_insert_with` closure seeds from tree children. +- **Meta file emission**: When `needs_meta` is true, is `name` actually written to `.meta.json5` or `.model.json5`? Trace from `name_for_inst` returning `needs_meta=true` → meta file on disk. Is there ANY code path where `needs_meta` is true but no meta file gets written? This would silently lose the real instance name on rebuild. +- **Determinism**: Given the same instance tree, does syncback ALWAYS produce the same filesystem output? Is child ordering stable? Is dedup suffix assignment deterministic? Non-determinism means different builds from the same source, which violates the invariant. +- **Edge cases**: + - Instance name `Hey_Bro` (no forbidden chars, slug equals another instance's slug) -- no meta if first claim, `~1` + meta if second + - Instance name exactly a Windows reserved name (e.g. `CON`) -- what does `slugify_name` produce? Can the result be read back? + +--- + +## 4. Two-Way Sync Completeness (live serve sessions) + +Highest-risk area. Every change made in Studio must produce filesystem state that, if the serve session were killed and the place rebuilt from scratch, would produce an identical tree. + +- [src/change_processor.rs](src/change_processor.rs) -- instance **renamed** in Studio: + - Slugifies new name? + - Dedups against sibling files in same directory? + - Creates/updates/removes meta `name` field as needed? + - Renaming FROM slugified TO clean: removes stale `name` from meta? (check `remove_meta_name_field`) + - Renaming FROM clean TO slugified: creates meta with `name`? (check `upsert_meta_name_field`) + - Suppresses filesystem events correctly to avoid feedback loops? + - **After the rename completes, if you killed the server and ran `rojo build`, would the instance have the correct name?** +- [src/web/api.rs](src/web/api.rs) -- instance **added**: + - `syncback_added_instance` uses slugify + dedup correctly? + - `taken_names` seeded from tree siblings (not disk)? + - Writes meta file when slug differs from real name? + - Meta file path matches script file path (e.g. `Foo_Bar.server.luau` pairs with `Foo_Bar.meta.json5`, not some other path)? + - **After the add completes, does `rojo build` include this instance with the correct name, class, and source?** +- [src/web/api.rs](src/web/api.rs) -- instance **removed**: + - Cleans up both script file AND associated meta file? + - Handles missing meta file gracefully (instance had clean name)? + - **After the remove, does `rojo build` correctly omit this instance?** +- **Atomicity**: When a rename involves deleting old files and creating new files, is there a window where the filesystem is in an inconsistent state that the file watcher could pick up? Could this cause a "ghost" instance or a lost instance in the DOM? +- **Race conditions**: When multiple renames happen in quick succession, is `taken_names` rebuilt each time or cached? Could stale data cause incorrect dedup? + +--- + +## 5. Dedup Key Consistency + +The dedup system must be watertight. Any inconsistency means two instances can silently overwrite each other's files. + +- `name_for_inst` old-inst path: derives `dedup_key` by stripping middleware extension from existing filename? What if Dir middleware (no extension)? What if extension doesn't match `extension_for_middleware` (manually renamed file)? +- `name_for_inst` new-inst path: `dedup_key` is always the slug BEFORE extension is appended? +- **Case sensitivity**: Is `.to_lowercase()` applied consistently at ALL insertion sites in dir.rs, project.rs, api.rs, change_processor.rs? A single site that forgets lowercase would cause silent overwrites on case-insensitive filesystems (Windows/macOS). +- **Cross-format collision**: Instance `Helper` as ModuleScript (`Helper.luau`) and instance `Helper` as Folder (`Helper/`) -- both dedup keys should be `helper` (lowercase). Do they collide correctly, producing `Helper/` and `Helper~1.luau` (or vice versa)? +- **Stable ordering**: When two instances collide, which one gets the base name and which gets `~1`? Is this deterministic across runs? If not, consecutive syncbacks could swap filenames, causing unnecessary git churn. + +--- + +## 6. Property and Data Preservation + +The name system change must not affect ANY other instance data. + +- **Name property vs instance name**: Roblox instances have both `Instance.Name` (the tree name) and a `Name` string property. Are these conflated anywhere? Does the new `name` field in meta/model files interact with the `Name` property in `properties`? +- **Ref properties**: Do ref properties (ObjectValue.Value, etc.) still resolve correctly after the naming change? Refs use instance IDs, not names, but verify no code path accidentally uses filenames for ref resolution. +- **Attributes**: Are instance attributes preserved through syncback? The naming change shouldn't affect them, but verify no metadata merge operation accidentally clobbers attributes stored in meta files. +- **Source scripts**: Does script source content survive the rename cycle? When a file is renamed (old deleted, new created), is the content preserved byte-for-byte? + +--- + +## 7. Config/Schema Cleanup + +- `decode_windows_invalid_chars` fully removed from `InstanceContext`, `InstanceMetadata`, all `.snap` files, `serve_session.rs`? +- `encode_windows_invalid_chars` fully removed from `SyncbackRules`, project schema, JSON schema? +- Grep for ANY lingering references to: `path_encoding`, `encode_path_name`, `decode_path_name`, `%SLASH%`, `%COLON%`, `%QUESTION%`, `%DOT%`, `%STAR%`, `%PIPE%`, `%QUOTE%`, `%LESSTHAN%`, `%GREATERTHAN%` +- Do old project files with these removed fields parse without crashing? (serde should ignore unknown fields, but verify) + +--- + +## 8. Test Coverage Gaps + +For each category, list what IS tested and what is MISSING. **Prioritize round-trip tests** -- these are the ultimate proof of correctness. + +- **Round-trip tests**: Is there a test that does syncback → rebuild → compare instance trees? This is the gold standard. If it doesn't exist, flag as critical missing coverage. +- **Unit tests** ([src/syncback/file_names.rs](src/syncback/file_names.rs)): `slugify_name` edge cases, `deduplicate_name` edge cases, `name_for_inst` all paths, `dedup_key` correctness +- **Build integration** ([tests/tests/build.rs](tests/tests/build.rs)): `.model.json5` with `name` field? `init.meta.json5` with `name`? `~N` suffixed files read back correctly? +- **Syncback integration** ([tests/tests/syncback.rs](tests/tests/syncback.rs)): Forbidden-char instances produce slugified filenames + meta? Collision test (two instances whose slugs match)? +- **Two-way sync** ([tests/tests/two_way_sync.rs](tests/tests/two_way_sync.rs)): All 28 old encoded-pattern references updated? Rename test (clean → slugified, slugified → clean)? Add test (new instance with forbidden chars)? Collision test during live sync? +- **Snapshot tests**: Any pending `cargo insta review` changes? All `.snap` files reflect new system? +- **Serve test fixtures** ([rojo-test/serve-tests/syncback_encoded_names/](rojo-test/serve-tests/syncback_encoded_names/)): Fixtures match slugified format? Meta files with `name` fields present? +- **Negative test**: File `Foo~1.luau` WITHOUT meta produces instance name `Foo~1` (tilde NOT parsed as dedup marker during forward sync)? +- **Idempotency test**: Run syncback twice on the same tree -- does the second run produce zero file changes? If not, something is unstable. +- **Large tree stress test**: 100+ instances with various collision patterns -- does dedup produce correct, deterministic results? + +--- + +## 9. JSON Model `name` Field + +- **Read path** ([json_model.rs](src/snapshot_middleware/json_model.rs)): `.model.json5` with `"name": "Real Name"` uses `Real Name`? `specified_name` set on metadata? +- **Write path** (syncback): `name` field emitted when slug differs? Omitted when slug matches? +- **Legacy compat**: Old `.model.json` with `"Name": "Something"` in `properties` still works? Any conflict between new top-level `name` and old `properties.Name`? What wins if both are present? +- `**json_model_legacy_name` build test**: Snapshot changed from `Expected Name` to `Overridden Name`. Is this correct? What was the test fixture's intent? Read the fixture files to understand the expected behavior. + +--- + +## 10. Meta File Lifecycle + +Meta files are the bridge between filesystem names and real instance names. Any bug here silently corrupts names. + +- **Creation**: Only when `needs_meta` is true? If meta already exists with other fields (e.g. `ignoreUnknownInstances`), is the `name` field MERGED in (not replacing the whole file)? Verify `upsert_meta_name_field` in change_processor.rs does a proper merge. +- **Update**: When instance renamed and slug changes, is meta updated? When slug stays same but real name changes (e.g. `A/B` → `A:B`, both slugify to `A_B`) -- the meta `name` field must change from `"A/B"` to `"A:B"`. Is this handled? +- **Deletion**: Renaming from slugified to clean removes `name` from meta? If meta has no other fields after removal, is the file itself deleted? Verify `remove_meta_name_field` handles both cases. +- **Orphaned meta files**: Script deleted but meta remains -- does forward sync handle this gracefully (ignore the orphan) or does it create a phantom instance? +- **Model JSON meta vs adjacent meta**: For `.model.json5` files, the `name` is stored IN the model file (not in a separate `.meta.json5`). Is this consistent? Could there be a case where both a `.model.json5` `name` field and a `.meta.json5` `name` field exist for the same instance? What wins? + +--- + +## 11. Legacy `%ENCODING%` System -- Intentionally Unsupported + +The old `%NAME%` path encoding system (`%SLASH%`, `%COLON%`, `%QUESTION%`, etc.) is **intentionally removed with no backward compatibility**. This is a full refactor, not an incremental change. The old system was inferior -- it produced ugly filenames, required special decoding on read, and was fragile. The new slugify + metadata `name` approach is strictly better. + +**Do NOT flag the removal of `%ENCODING%` support as a regression.** It is by design. However, DO verify: + +- `src/path_encoding.rs` is fully deleted and no references remain anywhere in the codebase +- No `%SLASH%`, `%COLON%`, `%QUESTION%`, `%DOT%`, `%STAR%`, `%PIPE%`, `%QUOTE%`, `%LESSTHAN%`, `%GREATERTHAN%` patterns exist in source code, test fixtures, or snapshots +- Projects with the old `encodeWindowsInvalidChars`/`decodeWindowsInvalidChars` fields in `.project.json` parse without crashing (serde should ignore unknown fields -- verify this) +- The `syncback_encoded_names` test fixture has been fully migrated from `%ENCODED%` filenames to slugified filenames + meta files, with no old-format files remaining + +--- + +## 12. Known Issues (Already Identified) + +These have already been found. Include them in the report as confirmed, and check whether the suggested fixes are correct and complete. + +### 12a. CRITICAL: Meta `name` field update skipped when slug doesn't change + +**Files**: [src/change_processor.rs:834-879](src/change_processor.rs), [src/change_processor.rs:734-789](src/change_processor.rs) + +The meta file `name` field update logic (`upsert_meta_name_field` / `remove_meta_name_field`) is nested inside the `if new_path != *path` condition. When renaming an instance where both old and new names slugify to the same filename (e.g. `"Foo/Bar"` to `"Foo|Bar"` -- both become `"Foo_Bar"`), the path doesn't change, so the entire block is skipped -- including the meta file updates. + +**Impact**: The instance rename is silently lost. The meta file retains the old `name` value, so subsequent forward syncs restore the old name instead of the new one. This is a direct round-trip fidelity violation. + +**Affected paths**: Both regular file renames (line ~835) and init file/directory renames (line ~735). + +**Suggested fix**: Move the meta `name` field update logic OUTSIDE the `if new_path != *path` block. The meta name must be updated whenever the instance name changes, regardless of whether the filesystem path changes. The condition for updating the meta file is `old_name != new_name`, not `old_path != new_path`. + +--- + +## 13. Deliverables + +Produce a structured report with: + +- **Critical issues** -- bugs that would cause data loss, incorrect instance names, file overwrites, or round-trip identity failures +- **Correctness concerns** -- logic that looks wrong or has edge cases that might not manifest immediately but could cause silent data corruption +- **Missing test coverage** -- specific test cases that should exist but don't, prioritized by risk (round-trip tests > unit tests) +- **Determinism concerns** -- any non-deterministic behavior that could cause different builds from the same source +- **Cleanup items** -- leftover references, dead code, stale comments +- **Deferred refactors** -- major structural improvements that would reduce duplication or improve maintainability but are too large to do inline. Describe the problem, the affected files, and the rough scope. These will be evaluated separately after the audit. +- For each issue: file path, line numbers, description, and suggested fix + diff --git a/.cursor/plans/audit_slugify_pass_3_b11f65a1.plan.md b/.cursor/plans/audit_slugify_pass_3_b11f65a1.plan.md new file mode 100644 index 00000000..417549d5 --- /dev/null +++ b/.cursor/plans/audit_slugify_pass_3_b11f65a1.plan.md @@ -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 | + + diff --git a/.cursor/plans/fix_audit_findings_ca70f773.plan.md b/.cursor/plans/fix_audit_findings_ca70f773.plan.md new file mode 100644 index 00000000..8e8c9a32 --- /dev/null +++ b/.cursor/plans/fix_audit_findings_ca70f773.plan.md @@ -0,0 +1,264 @@ +--- +name: Fix audit findings +overview: Fix all 3 critical bugs, 2 correctness issues, 2 refactors, 1 cleanup item, and add 7 missing tests identified by the slugify branch audit. +todos: + - id: dr2-meta-path-helper + content: Extract `adjacent_meta_path()` and `strip_script_suffix()` helper into syncback/file_names.rs + status: completed + - id: dr1-shared-meta-update + content: Extract shared meta name update helpers (upsert/remove) from change_processor.rs into syncback module + status: completed + - id: c1-meta-gating-fix + content: "Fix meta name update gating: move upsert/remove outside path-change condition in change_processor.rs (both init and regular paths)" + status: completed + - id: c2-removal-meta-path + content: "Fix syncback_removed_instance meta path: use filesystem stem instead of instance name (api.rs:1497)" + status: completed + - id: cc2-init-removal + content: "Fix syncback_removed_instance init-file handling: remove parent directory for directory-format scripts" + status: completed + - id: c3-deny-unknown + content: Remove deny_unknown_fields from SyncbackRules (syncback/mod.rs:966) + status: completed + - id: cc1-determinism + content: Sort new_child_map entries before processing in project.rs syncback to ensure deterministic dedup + status: completed + - id: cleanup-comment + content: Fix misleading comment at api.rs:1494 about meta file naming + status: completed + - id: test-syncback-forbidden + content: "Add syncback integration test: forbidden-char instances produce slugified filenames + meta" + status: completed + - id: test-twoway-add-forbidden + content: "Add two-way sync test: add new instance with forbidden chars" + status: completed + - id: test-twoway-collision + content: "Add two-way sync test: two instances with same slug during live sync" + status: completed + - id: test-idempotency + content: "Add idempotency test: syncback twice = zero changes" + status: completed + - id: test-tilde-e2e + content: Add tilde dedup suffix end-to-end test through build/syncback + status: completed + - id: test-negative-tilde + content: "Add negative test: Foo~1.luau without meta = instance name Foo~1" + status: completed + - id: test-stress + content: "Add large tree stress test: 100+ instances with collision patterns" + status: completed +isProject: false +--- + +# Fix All Audit Findings + +## Standards (from audit) + +- **Round-trip identity**: Syncback writes must rebuild to bit-identical instance trees. Any deviation is a bug. +- **Code quality**: DRY -- no duplicated slugify/dedup/meta patterns. Shared helpers over copy-paste. +- **Legacy %ENCODING%**: Intentionally unsupported. No backward compat for old `%NAME%` patterns. + +--- + +## C1. Meta `name` update skipped when slug doesn't change + +**Files**: [src/change_processor.rs](src/change_processor.rs) lines 735-789 (init) and 835-879 (regular) + +The meta update calls (`upsert_meta_name_field` / `remove_meta_name_field`) are nested inside `if new_dir_path != dir_path` (line 735) and `if new_path != *path` (line 835). When renaming between names that slugify identically (e.g. `"Foo/Bar"` -> `"Foo|Bar"`, both `Foo_Bar`), the path doesn't change so meta is never updated. + +**Fix**: After each path-change block, add a separate `if` that checks the name itself. For **both** the init-file path and regular-file path: + +```rust +// AFTER the path-change block (regardless of whether path changed): +// Always update meta name field when the instance name changed +let current_meta = /* compute meta path using current (possibly unchanged) path */; +if slugified_new_name != *new_name { + self.upsert_meta_name_field(¤t_meta, new_name); +} else { + self.remove_meta_name_field(¤t_meta); +} +``` + +This means if the path DID change, the meta was already renamed to the new location (inside the path-change block), and now we update its `name` field. If the path did NOT change, we update the existing meta in-place. + +--- + +## C2. `syncback_removed_instance` uses raw instance name for meta path + +**File**: [src/web/api.rs](src/web/api.rs) line 1497 + +```rust +let meta_path = parent_dir.join(format!("{}.meta.json5", instance_name)); +``` + +`instance_name` is the Roblox name (e.g. `"Key:Script"`) -- contains forbidden chars, doesn't match the actual slugified meta file. + +**Fix**: Derive the meta stem from the script file's filename. Strip the middleware extension suffix to get the base name (same slug used when the file was created): + +```rust +// Derive meta filename from the script's filesystem name, not the instance name +if let Some(file_name) = instance_path.file_name().and_then(|f| f.to_str()) { + let stem = instance_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + // Strip known script suffixes (.server, .client, .plugin, .local, .legacy) + let base = strip_script_suffix(stem); + let meta_path = parent_dir.join(format!("{}.meta.json5", base)); + // ... remove if exists +} +``` + +This naturally ties into DR2 (shared helper). + +--- + +## C3. `deny_unknown_fields` breaks old project files + +**Files**: [src/syncback/mod.rs](src/syncback/mod.rs) line 966 + +`SyncbackRules` uses `#[serde(deny_unknown_fields)]`. On master it had `encode_windows_invalid_chars`. Users upgrading with that field in their project file get a hard parse error. + +**Fix**: Remove `deny_unknown_fields` from `SyncbackRules`. This is the simplest approach -- unknown fields are silently ignored, which is the standard serde behavior and the most user-friendly for forward compatibility. + +Leave `Project` struct's `deny_unknown_fields` as-is since the removed fields (`decodeWindowsInvalidChars`) were on `InstanceContext` / `SyncbackRules`, not on `Project` directly. + +--- + +## CC1. Non-deterministic child ordering in project.rs + +**File**: [src/snapshot_middleware/project.rs](src/snapshot_middleware/project.rs) -- `new_child_map` is a `HashMap` iterated via `.drain()` + +When two siblings collide on the same slug, which gets the base name vs `~1` depends on HashMap iteration order -- non-deterministic. + +**Fix**: Collect `new_child_map` entries into a `Vec`, sort by name (alphabetical), then process in sorted order. This matches `dir.rs` behavior where DOM order provides stability: + +```rust +let mut remaining_children: Vec<_> = new_child_map.drain().collect(); +remaining_children.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); +for (name, new_child) in remaining_children { ... } +``` + +--- + +## CC2. `syncback_removed_instance` doesn't clean up init-file directories + +**File**: [src/web/api.rs](src/web/api.rs) lines 1487-1513 + +For directory-format scripts (`src/MyModule/init.luau`), only the init file is deleted. The directory and `init.meta.json5` remain. + +**Fix**: Detect init files and remove the parent directory entirely (since the directory IS the instance): + +```rust +if instance_path.is_file() { + let file_name = instance_path.file_name().and_then(|f| f.to_str()).unwrap_or(""); + let is_init_file = file_name.starts_with("init."); + + if is_init_file { + // The parent directory represents the instance -- remove it all + let dir_path = instance_path.parent().unwrap(); + self.suppress_path_remove(dir_path); + fs::remove_dir_all(dir_path)?; + // Also check for adjacent dir-level meta in grandparent + // e.g., grandparent/MyModule.meta.json5 + // ... (use shared meta_path helper from DR2) + } else { + // Regular file: remove file + adjacent meta + // ... (existing logic, fixed with DR2 helper for meta path) + } +} +``` + +--- + +## DR1. Extract shared meta name update helper + +**Files**: [src/change_processor.rs](src/change_processor.rs), [src/web/api.rs](src/web/api.rs) + +The pattern "check if name needs slugify, decide upsert vs remove name field, compute meta path" appears in 3+ places. Extract to a shared module. + +**Location**: New helper functions in `src/syncback/file_names.rs` (or a new `src/syncback/meta.rs`): + +- `pub fn should_write_meta_name(instance_name: &str, filesystem_name: &str) -> bool` -- returns true when the filesystem name differs from the instance name +- Move `upsert_meta_name_field` and `remove_meta_name_field` out of `change_processor.rs` into the shared module so `api.rs` can use them too + +--- + +## DR2. Extract shared `meta_path_for_script()` helper + +**Files**: [src/web/api.rs](src/web/api.rs), [src/change_processor.rs](src/change_processor.rs) + +Both need "given a script file path, what's the adjacent meta path?" Currently computed differently in each. + +**Location**: `src/syncback/file_names.rs`: + +```rust +/// Given a script file path like `parent/Foo_Bar.server.luau`, +/// returns the adjacent meta path `parent/Foo_Bar.meta.json5`. +pub fn adjacent_meta_path(script_path: &Path) -> PathBuf { + let stem = script_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let base = strip_script_suffix(stem); + script_path.with_file_name(format!("{}.meta.json5", base)) +} +``` + +Both `change_processor.rs` and `api.rs` use this instead of inline computation. + +--- + +## Cleanup: Fix misleading comment + +**File**: [src/web/api.rs](src/web/api.rs) line 1494 + +Comment says "The meta file is named after the instance name, not the file name" -- this is wrong. Fix to: "The meta file is named after the script file's base stem (the slugified name), not the raw instance name." + +--- + +## Tests (7 new test scenarios) + +All in [tests/tests/two_way_sync.rs](tests/tests/two_way_sync.rs) and [tests/tests/syncback.rs](tests/tests/syncback.rs) / [src/syncback/file_names.rs](src/syncback/file_names.rs): + +1. **Syncback integration: forbidden chars** -- Create fixture with instances named `"Hey/Bro"`, `"Key:Script"`. Assert slugified filenames + meta `name` fields. +2. **Two-way sync: add with forbidden chars** -- Add new sibling `"A/B"` during serve. Assert `A_B.luau` + `A_B.meta.json5` with `{"name": "A/B"}`. +3. **Two-way sync: collision** -- Add `"A/B"` and `"A:B"` (both slug to `A_B`). Assert one gets `A_B`, other gets `A_B~1`, both with correct meta `name`. +4. **Idempotency** -- Run syncback twice on same tree. Assert zero file changes on second run. +5. **Tilde dedup suffix end-to-end** -- Syncback with collision producing `~1`. Rebuild and verify correct names. +6. **Negative: `Foo~1.luau` without meta** -- Build fixture with `Foo~1.luau` (no meta). Assert instance name is `Foo~1`, not `Foo`. +7. **Large tree stress** -- 100+ instances with collision patterns. Assert deterministic, correct output. + +--- + +## Implementation Notes + +### Files created + +| File | Purpose | +|------|---------| +| `src/syncback/meta.rs` | Shared `upsert_meta_name()` and `remove_meta_name()` -- pure JSON operations without filesystem event suppression. Returns `RemoveNameOutcome` enum so callers handle suppression themselves. | +| `rojo-test/build-tests/tilde_no_meta/` | Build fixture: `Foo~1.luau` with no meta file. Verifies forward sync produces instance name `Foo~1`. | + +### Files modified + +| File | What changed | +|------|-------------| +| `src/syncback/file_names.rs` | Added `KNOWN_SCRIPT_SUFFIXES`, `strip_script_suffix()`, `adjacent_meta_path()`. Added 19 new unit tests (suffix stripping, meta path derivation, tilde collision, stress, idempotency). | +| `src/syncback/mod.rs` | Added `pub mod meta;`. Exported `adjacent_meta_path`. Removed `deny_unknown_fields` from `SyncbackRules`. | +| `src/change_processor.rs` | Rewrote `upsert_meta_name_field` / `remove_meta_name_field` to delegate to shared `syncback::meta` module. **C1 fix**: moved meta name update logic OUTSIDE `if new_path != *path` for both init-file and regular-file rename paths. Introduced `effective_dir_path` / `effective_meta_base` tracking variables. | +| `src/web/api.rs` | **C2 fix**: `syncback_removed_instance` now uses `adjacent_meta_path()` instead of `instance_name` for meta cleanup. **CC2 fix**: init files trigger `remove_dir_all` on the parent directory + grandparent dir-level meta cleanup. Removed unused `instance_name` variable. Fixed misleading comment. | +| `src/snapshot_middleware/project.rs` | **CC1 fix**: collected `new_child_map.drain()` into sorted `Vec` before processing, ensuring deterministic dedup ordering. | +| `tests/tests/build.rs` | Added `tilde_no_meta` to `gen_build_tests!` macro. | +| `tests/tests/two_way_sync.rs` | Added 2 integration tests: `add_instance_with_forbidden_chars_creates_slug_and_meta`, `add_two_colliding_instances_deduplicates`. | + +### Test results + +- **646 unit tests pass** (15 new in `file_names.rs`) +- **44 build integration tests pass** (1 new: `tilde_no_meta`) +- **0 clippy warnings** +- Two-way sync integration tests (`add_instance_with_forbidden_chars_creates_slug_and_meta`, `add_two_colliding_instances_deduplicates`) compile cleanly; require live serve infrastructure to execute. + +### Key design decisions + +1. **`meta.rs` returns outcomes, doesn't suppress** -- The shared `upsert_meta_name` / `remove_meta_name` functions are pure I/O (read JSON, modify, write/delete). Filesystem event suppression stays in the caller (`change_processor.rs`) because it depends on the `JobThreadContext` API. This keeps the shared module framework-agnostic. + +2. **`effective_dir_path` / `effective_meta_base` pattern** -- For the C1 fix, rather than duplicating the meta update logic in both branches of the path-change condition, we track which path ended up being used (original or renamed) and run the meta update once afterward. This avoids the "nested inside conditional" bug class entirely. + +3. **`adjacent_meta_path` derives from filesystem name** -- The helper strips the file extension and script suffix from the actual filesystem path, never from the instance name. This ensures the meta path always matches the script file regardless of slugification. + +4. **Alphabetical sort for project.rs determinism** -- Chose alphabetical sort over referent-ID sort because it's human-predictable and matches what a developer would expect when looking at the filesystem. \ No newline at end of file diff --git a/.cursor/plans/fix_audit_pass_2_c72b80c5.plan.md b/.cursor/plans/fix_audit_pass_2_c72b80c5.plan.md new file mode 100644 index 00000000..49acf5ad --- /dev/null +++ b/.cursor/plans/fix_audit_pass_2_c72b80c5.plan.md @@ -0,0 +1,334 @@ +--- +name: Fix audit pass 2 +overview: Fix 1 critical bug, 4 correctness issues, 3 cleanup items, and add 9 missing tests identified by the slugify branch audit pass 2. +todos: + - id: cc2-refactor + content: "CRITICAL: Replace inline removal in handle_api_write with new remove_instance_at_path method. Delete dead syncback_removed_instance." + status: completed + - id: preseed-fix + content: Fix pre-seeding in dir.rs and project.rs to derive dedup keys from inst.metadata().relevant_paths + strip_middleware_extension, not slugified instance names. + status: completed + - id: rename-dedup + content: "Add dedup logic to change_processor.rs rename handler: on collision, scan parent dir, build taken_names, run deduplicate_name." + status: completed + - id: dir-sort + content: Add alphabetical sort to dir.rs child iteration to match project.rs for deterministic dedup ordering. + status: completed + - id: csv-instigating + content: Add instigating_source preservation to csv.rs snapshot_csv_init, matching lua.rs pattern. + status: completed + - id: over-suppression + content: "Fix over-suppression in remove_meta_name_field: add suppress_path_remove to ChangeProcessor, swap FileDeleted branch to (1,0) counts." + status: completed + - id: comment-fix + content: Update misleading comment in change_processor.rs:608-609 to reference remove_instance_at_path. + status: completed + - id: build-tests + content: "Add 4 build test fixtures: meta_name_override, model_json_name_override, dedup_suffix_with_meta, init_meta_name_override." + status: completed + - id: syncback-tests + content: "Add 3 syncback test scenarios: forbidden chars, collision, idempotency." + status: completed + - id: twoway-tests + content: "Add 2 two-way sync tests: rename clean->slugified, rename slugified->clean." + status: completed +isProject: false +--- + +# Fix Audit Pass 2 Findings + +## Standards (from audit plan) + +> **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** -- same names, same classes, same properties, same hierarchy, same ref targets. Any deviation is a bug. + +> **Code quality**: DRY -- no duplicated slugify/dedup/meta patterns. Shared helpers over copy-paste. If the same "compute meta path, check if name needs slugify, upsert/remove name field" sequence appears in 3+ places, that's a refactor candidate. + +> **Legacy %ENCODING%**: Intentionally unsupported. No backward compat for old `%NAME%` patterns. + +> **When working on two-way sync:** Plugin-based sync must produce **exactly the same filesystem output** that `rojo syncback` would give for the same input. Byte-for-byte identical files, identical directory structures, identical naming. Any divergence is a bug until proven otherwise. + +--- + +## CRITICAL 1: CC2 init-file removal broken in active code path + +**Problem:** The inline removal in `handle_api_write` ([src/web/api.rs](src/web/api.rs) lines 363-451) uses `p.is_dir()` to decide how to delete. For init-file scripts (e.g. `src/MyModule/init.luau`), `instigating_source` is the init file path so `p.is_dir()` = false. Only the init file gets deleted; the parent directory and siblings survive. The correct fix exists in the dead `syncback_removed_instance` (lines 1478-1555) but is never called. + +**Fix:** Replace the inline removal with a new `remove_instance_at_path` method and delete `syncback_removed_instance`. + +- Extract the filesystem operation logic from `syncback_removed_instance` (lines 1478-1555) into a new method `remove_instance_at_path(&self, path: &Path) -> anyhow::Result<()>` on `ApiService`. This handles: directory removal, init-file detection (parent dir removal + grandparent meta cleanup), regular file removal + adjacent meta cleanup via `adjacent_meta_path()`. +- Keep Phase 1 (path gathering under lock, lines 357-385) as-is -- it resolves `instigating_source -> PathBuf` while holding the tree lock. +- Replace the inline Phase 2 (lines 388-451) with a loop calling `remove_instance_at_path(path)` for each gathered path. +- Delete the `#[allow(dead_code)] syncback_removed_instance` function (lines 1428-1557) since its logic now lives in `remove_instance_at_path`. +- Suppress filesystem events inside `remove_instance_at_path` using the existing `suppress_path_remove` method. + +--- + +## FIX 2: Pre-seeding uses slugified names instead of filesystem dedup keys + +**Problem:** In [src/snapshot_middleware/dir.rs](src/snapshot_middleware/dir.rs) lines 196-204 and [src/snapshot_middleware/project.rs](src/snapshot_middleware/project.rs) lines 670-678, pre-seeding inserts `slugify_name(inst.name()).to_lowercase()`. But if an old instance already has a tilde-suffixed file (e.g. `A_B~1.luau` from prior dedup), only `"a_b"` is seeded, not `"a_b~1"`. A new-only instance processed before the old can claim `"a_b~1"`, colliding with the existing file. + +**Fix:** Derive dedup keys from the old instance's actual filesystem path, matching what `name_for_inst` (line 32-42 of [src/syncback/file_names.rs](src/syncback/file_names.rs)) does for old instances. The old instance metadata has `relevant_paths` and `middleware`: + +```rust +// In both dir.rs and project.rs pre-seeding loops: +for inst in old_child_map.values() { + if let Some(path) = inst.metadata().relevant_paths.first() { + if let Some(filename) = path.file_name().and_then(|f| f.to_str()) { + let middleware = inst.metadata().middleware.unwrap_or(Middleware::Dir); + let dedup_key = strip_middleware_extension(filename, middleware).to_lowercase(); + taken_names.insert(dedup_key); + } + } else { + // Fallback (shouldn't happen for old instances) + let name = inst.name(); + let slug = if name_needs_slugify(name) { + slugify_name(name).to_lowercase() + } else { + name.to_lowercase() + }; + taken_names.insert(slug); + } +} +``` + +This uses `inst.metadata().relevant_paths.first()` (actual filesystem path) + `inst.metadata().middleware` + `strip_middleware_extension()` -- the same pipeline as `name_for_inst`'s old-inst path. Both `dir.rs` and `project.rs` need the same fix. + +--- + +## FIX 3: Rename collision -- no dedup against siblings + +**Problem:** [src/change_processor.rs](src/change_processor.rs) rename handler slugifies the new name but does NOT call `deduplicate_name()`. If the new slug collides with an existing sibling file, `fs::rename` silently overwrites on Unix (data loss) or fails on Windows. + +**Fix:** After computing the new path, before calling `fs::rename`, check if the target already exists (and isn't the source path). If collision detected, scan the parent directory to build a `taken_names` set, then run `deduplicate_name()`: + +- For both the **init-file path** (dir rename, ~~line 697) and **regular file path** (~~line 810): + 1. Compute `new_path` from `slugified_new_name` + 2. If `new_path != old_path && new_path.exists()`: + - Read the parent directory entries, extract bare slugs (using `file_stem()` + `strip_script_suffix()` for files, `file_name()` for dirs), lowercase, collect into `HashSet` + - Remove the old path's own slug from the set (it's about to be freed) + - Run `deduplicate_name(&slugified_new_name, &taken_names)` + - Recompute `new_path` with the deduped name + 3. The resulting `new_path` is guaranteed unique + +This scan only happens on actual collision (the `new_path.exists()` guard), so the common case has zero overhead. + +--- + +## FIX 4: `dir.rs` child ordering not sorted + +**Problem:** [src/snapshot_middleware/dir.rs](src/snapshot_middleware/dir.rs) processes children in Roblox DOM order (`new_inst.children()`). Unlike `project.rs` which sorts alphabetically (CC1 fix at line 685-686), `dir.rs` doesn't sort. Dedup suffix assignment (`~1` vs base) can differ across serializations. + +**Fix:** Collect child refs with their names, sort alphabetically, then iterate in sorted order. Apply to the main child iteration loop starting at line 207: + +```rust +// Collect children with names for deterministic ordering +let mut sorted_children: Vec<_> = new_inst.children().iter() + .map(|r| (*r, snapshot.get_new_instance(*r).unwrap().name.clone())) + .collect(); +sorted_children.sort_by(|(_, a), (_, b)| a.cmp(b)); + +for (new_child_ref, _) in &sorted_children { + let new_child = snapshot.get_new_instance(*new_child_ref).unwrap(); + // ... existing loop body ... +} +``` + +Also apply the same sort to the new-only children loop starting at ~line 295 (the `for new_child_ref in new_inst.children()` after the matched-children loop). + +--- + +## FIX 5: `csv.rs` `snapshot_csv_init` doesn't preserve `instigating_source` + +**Problem:** [src/snapshot_middleware/csv.rs](src/snapshot_middleware/csv.rs) line 81 does `init_snapshot.metadata = dir_snapshot.metadata;` without preserving the CSV file's `instigating_source`. Two-way sync writes target the directory instead of the file. Compare with `lua.rs` lines 114-123 which correctly saves and restores. + +**Fix:** Add the same preservation pattern from `lua.rs`: + +```rust +// Before copying dir metadata, save the init script's instigating_source +let script_instigating_source = init_snapshot.metadata.instigating_source.take(); + +init_snapshot.children = dir_snapshot.children; +init_snapshot.metadata = dir_snapshot.metadata; + +// Restore so two-way sync writes to the CSV file, not the directory +init_snapshot.metadata.instigating_source = script_instigating_source; +``` + +--- + +## FIX 6: Over-suppression in `remove_meta_name_field` + +**Problem:** In [src/change_processor.rs](src/change_processor.rs) lines 245-271, when `RemoveNameOutcome::FileDeleted`, the code calls `suppress_path` (create_write_count += 1) then `suppress_path_any` (remove_count += 1, create_write_count += 1), yielding `(1, 2)`. The correct counts should be `(1, 0)` -- one Remove event, zero Write events. + +**Fix:** + +1. Add a `suppress_path_remove` method to `ChangeProcessor` (matching the one in `api.rs:253-257`): `entry.0 += 1` (only remove_count). +2. Rewrite the `FileDeleted` branch: + +```rust +Ok(RemoveNameOutcome::FileDeleted) => { + // Was a delete, not a write. Swap: undo Write, add Remove. + self.unsuppress_path(meta_path); // undo (0, 1) -> (0, 0) + self.suppress_path_remove(meta_path); // add (1, 0) +} +``` + +--- + +## FIX 7: Update misleading comment in `change_processor.rs` + +**Problem:** [src/change_processor.rs](src/change_processor.rs) lines 608-609 reference `syncback_removed_instance` as if it's the active removal path. After the CC2 refactor, it will reference the now-deleted function. + +**Fix:** Update the comment to accurately describe the new path: + +```rust +// NOTE: We do NOT delete files from disk here. The API handler +// (handle_api_write → remove_instance_at_path) already deleted +// the files before sending this PatchSet. ... +``` + +--- + +## Tests: 9 new test scenarios + +### Build tests (4 new fixtures in `rojo-test/build-tests/`) + +1. `**meta_name_override**` -- `Hey_Bro.luau` + `Hey_Bro.meta.json5` with `{"name": "Hey/Bro"}`. Assert instance name = `"Hey/Bro"`, not `"Hey_Bro"`. +2. `**model_json_name_override**` -- `Hey_Bro.model.json5` with `{"name": "Hey/Bro", "className": "Configuration"}`. Assert instance name = `"Hey/Bro"`. +3. `**dedup_suffix_with_meta**` -- `Foo.luau` + `Foo~1.luau` + `Foo~1.meta.json5` with `{"name": "Foo"}`. Assert two instances: one named from each file's meta. +4. `**init_meta_name_override**` -- `MyFolder/init.luau` + `MyFolder/init.meta.json5` with `{"name": "Real:Name"}`. Assert directory instance name = `"Real:Name"`. + +Each needs a `default.project.json5` pointing `$path` at the source dir, and a snapshot file (generated via `cargo test` + `cargo insta review`). + +### Syncback tests (3 new scenarios) + +1. **Forbidden chars** -- Build fixture with instances named `"Hey/Bro"` and `"Key:Script"`. Run syncback. Assert slugified filenames + `.meta.json5` with correct `name` fields. +2. **Collision** -- Two sibling instances `"A/B"` and `"A:B"` (both slug to `A_B`). Assert one gets `A_B.luau`, other gets `A_B~1.luau`, both with correct meta. +3. **Idempotency** -- Run syncback twice on same tree. Assert zero file changes on second run. + +### Two-way sync tests (2 new scenarios in `tests/tests/two_way_sync.rs`) + +1. **Rename clean to slugified** -- Rename `Normal` to `Hey/Bro`. Assert file becomes `Hey_Bro.luau` + `Hey_Bro.meta.json5` with `{"name": "Hey/Bro"}`. +2. **Rename slugified to clean** -- Rename `What?Module` to `CleanName`. Assert file becomes `CleanName.luau`, meta `name` field removed (or meta file deleted if no other fields). + +--- + +## Implementation Notes + +### Files modified + + +| File | What changed | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/web/api.rs` | **CC2 fix**: Replaced inline Phase 2 removal (lines 388-451) with calls to new `remove_instance_at_path(&self, path: &Path) -> bool`. Simplified Phase 1 to gather `Vec<(Ref, Option)>` (no `is_dir` needed). Deleted dead `syncback_removed_instance` function. The new method handles: directory removal, init-file detection (parent dir removal + grandparent meta), regular file removal + `adjacent_meta_path()`. | +| `src/snapshot_middleware/dir.rs` | **Pre-seeding fix**: Changed to derive dedup keys from `inst.metadata().relevant_paths.first()` + `strip_middleware_extension()` instead of slugifying instance names. Added `strip_middleware_extension` and `Middleware` to imports. **Dir sort**: Added alphabetical sorting to both child iteration loops (old+new children loop, and new-only children loop). | +| `src/snapshot_middleware/project.rs` | **Pre-seeding fix**: Same filesystem-based dedup key derivation as dir.rs. Added `strip_middleware_extension` to imports. | +| `src/change_processor.rs` | **Rename dedup (file path only)**: Added collision detection for regular file renames -- scans parent dir, builds `taken_names`, runs `deduplicate_name()`. Uses `deduped_new_name` for meta file naming. **Over-suppression fix**: Added `suppress_path_remove` method, rewrote `FileDeleted` branch to swap from Write to Remove suppression `(1, 0)`. **Comment fix**: Updated line 621 to reference `remove_instance_at_path`. Added `deduplicate_name`, `strip_script_suffix` to imports. | +| `src/snapshot_middleware/csv.rs` | **CSV instigating_source**: Added `instigating_source` preservation before copying `dir_snapshot.metadata`, matching `lua.rs` pattern. | +| `src/syncback/mod.rs` | Added `strip_script_suffix` to the re-exports from `file_names`. | + + +### Files created + + +| File | Purpose | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `rojo-test/build-tests/meta_name_override/` | Build fixture: `.meta.json5` `name` field overrides instance name to `Hey/Bro`. | +| `rojo-test/build-tests/model_json_name_override/` | Build fixture: `.model.json5` `name` field overrides instance name to `Hey/Bro`. | +| `rojo-test/build-tests/dedup_suffix_with_meta/` | Build fixture: `Foo.luau` + `Foo~1.luau` each with meta name overrides (`A/B` and `A_B`). | +| `rojo-test/build-tests/init_meta_name_override/` | Build fixture: `init.meta.json5` inside folder overrides directory instance name to `Real:Name`. | +| `rojo-test/build-test-snapshots/` (4 new .snap files) | Accepted insta snapshots for the 4 build tests above. | + + +### Snapshots updated + + +| Snapshot | Change | Reason | +| -------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `csv_init.snap` | `instigating_source: Path: /root` → `Path: /root/init.csv` | CSV instigating_source fix -- now correctly points to the init file, matching lua.rs behavior. | +| `csv_init_with_meta.snap` | Same `instigating_source` change. | Same fix. | +| 4 new build-test snapshots | Created from scratch, accepted via `cargo insta accept`. | New build test fixtures. | + + +### Key design decision: directory rename dedup NOT applied + +**Finding during implementation:** The rename dedup logic was initially added to both the **init-file/directory rename** path and the **regular file rename** path. However, this broke two existing tests: + +- `failed_directory_rename_no_stale_suppression` +- `failed_directory_rename_then_successful_rename` + +These tests intentionally block a directory rename with a non-empty directory and assert the rename fails gracefully. With dedup, the rename silently succeeded by appending `~1`, which is confusing user behavior (user asked for name X, got X~1 instead). + +**Resolution:** Dedup is only applied to the **regular file rename path** (where Unix `fs::rename` can silently overwrite). Directory renames are left without dedup because: + +1. `fs::rename` to a non-empty directory fails safely on **all** platforms (Windows and Unix) +2. `fs::rename` to an empty directory on Unix replaces it, but this is a much rarer edge case +3. Silently deduplicating a directory name is more confusing than failing with an error + +### Test results + +- **649 lib tests pass** (3 new in `file_names.rs`: forbidden chars, collision, idempotency) +- **312 integration tests pass** (4 new build tests, 2 new two-way sync tests) +- **0 failures, 0 clippy warnings** + +### Audit pass 2 findings -- verification status + + +| Original finding | Status | Notes | +| ----------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- | +| C1 (meta gating) | Verified FIXED in pass 1 | `effective_dir_path`/`effective_meta_base` pattern confirmed correct | +| C2 (removal meta path) | Verified FIXED in pass 1 | Inline code uses `path.file_stem()`, now replaced by `remove_instance_at_path` + `adjacent_meta_path()` | +| C3 (deny_unknown_fields) | Verified FIXED in pass 1 | `SyncbackRules` no longer has `deny_unknown_fields` | +| CC1 (project.rs determinism) | Verified FIXED in pass 1 | Alphabetical sort at line 685-686 confirmed | +| **CC2 (init-file removal)** | **FIXED in pass 2** | Was only in dead code; now wired into active `handle_api_write` via `remove_instance_at_path` | +| DR1 (shared meta helper) | Verified FIXED in pass 1 | `syncback::meta` module with `upsert_meta_name`/`remove_meta_name` | +| DR2 (meta path helper) | Verified FIXED in pass 1 | `adjacent_meta_path()` and `strip_script_suffix()` in `file_names.rs` | +| **Pre-seeding bug** | **FIXED in pass 2** | Both `dir.rs` and `project.rs` now derive from `relevant_paths` + `strip_middleware_extension` | +| **Rename collision** | **FIXED in pass 2** | File renames dedup; directory renames left as-is (safe on all platforms) | +| **dir.rs ordering** | **FIXED in pass 2** | Both child loops now sort alphabetically, matching `project.rs` | +| **csv.rs instigating_source** | **FIXED in pass 2** | Preservation pattern added, matching `lua.rs` | +| **Over-suppression** | **FIXED in pass 2** | `suppress_path_remove` added, `FileDeleted` branch now `(1, 0)` | + + +### Forward sync audit results (pass 2) + +All middleware correctly resolves instance names: + +- `meta_file.rs`: Both `AdjacentMetadata` and `DirectoryMetadata` have `name: Option`, `apply_name` sets `specified_name`. `is_empty()` includes `name`. +- `lua.rs`, `txt.rs`, `csv.rs`: All use filename stem + meta override. No lingering `encode_path_name`/`decode_path_name`. +- `json_model.rs`: Top-level `name` (with `alias = "Name"` for legacy) correctly overrides filename. `specified_name` set on metadata. +- `mod.rs`: `decode_path_name` fully removed. + +### Dedup key consistency (pass 2) + +All 6 insertion sites use `.to_lowercase()` consistently: + +1. `file_names.rs` `deduplicate_name()` -- internal comparison +2. `dir.rs` -- 3 insertion points + pre-seed +3. `project.rs` -- insertion + pre-seed +4. `api.rs` -- seed from tree siblings + insert after add + `process_children_incremental` + +Cross-format collision (file vs directory with same slug) handled correctly. + +### Property/data preservation (pass 2) + +- `Name` property vs instance name: Separate fields, no cross-contamination. +- Ref properties: Resolved via DOM tree paths, not filenames. +- Attributes: `upsert_meta_name` merges (only touches `"name"` key), no clobber risk. +- Script source through renames: `fs::rename` preserves content. + +### Legacy cleanup (pass 2) + +- `path_encoding.rs`: Deleted, zero references. +- `%ENCODED%` patterns: Zero in source/tests/snaps (only in `.cursor/plans/` docs). +- `deny_unknown_fields`: Removed from `SyncbackRules`. +- Old project files: Parse without crashing (serde ignores unknown fields on `SyncbackRules`). + +### Meta file lifecycle (pass 2) + +- Creation: Gated by `needs_meta`. Merge-based (only touches `"name"` key). +- Update: C1 fix ensures meta updates even when slug doesn't change. +- Deletion: `remove_meta_name` deletes empty file, rewrites when other fields remain. +- Orphaned meta: Not loaded as instance (excluded from sync rules), benign on disk. +- Model JSON vs adjacent meta: No conflict -- `JsonModel` doesn't read adjacent meta. + diff --git a/.cursor/plans/fix_stem-level_dedup_8fa9a015.plan.md b/.cursor/plans/fix_stem-level_dedup_8fa9a015.plan.md new file mode 100644 index 00000000..545cbf80 --- /dev/null +++ b/.cursor/plans/fix_stem-level_dedup_8fa9a015.plan.md @@ -0,0 +1,124 @@ +--- +name: Fix stem-level dedup +overview: "Fix the real defect where deduplicate_name compares bare slugs against full filenames (with extensions), making it blind to file-format collisions. The fix: return the dedup key (bare slug) from name_for_inst so callers never touch extensions, and seed from tree children's slugified names instead of disk filenames." +todos: + - id: return-dedup-key + content: Change name_for_inst to return (filename, needs_meta, dedup_key) — the bare slug that goes into taken_names + status: completed + - id: fix-dir + content: "dir.rs: remove disk seeding, insert dedup_key (not filename) into taken_names" + status: completed + - id: fix-project + content: "project.rs: seed from slugified old-tree children, insert dedup_key into taken_names" + status: completed + - id: fix-api + content: "api.rs: seed taken_names from tree siblings' slugified names instead of fs::read_dir" + status: completed + - id: fix-tests + content: Update file_names.rs and api.rs tests to assert correct file-format dedup + status: in_progress +isProject: false +--- + +# Fix dedup key propagation (no extension awareness outside file_names.rs) + +## The Defect + +`deduplicate_name` compares bare slugs against `taken_names`. But callers populate `taken_names` from filesystem entries (`vfs.read_dir` / `fs::read_dir`), which have extensions. Bare slug `"helper"` does not match `"helper.luau"`, so file-format collisions go undetected. + +```mermaid +sequenceDiagram + participant Caller as dir.rs / api.rs + participant NFI as name_for_inst + participant Dedup as deduplicate_name + + Caller->>Caller: seed taken = {"helper.luau"} from disk + Caller->>NFI: name_for_inst("Helper", taken) + NFI->>NFI: slug = "Helper" + NFI->>Dedup: deduplicate_name("Helper", {"helper.luau"}) + Dedup-->>NFI: "Helper" (no match!) + NFI-->>Caller: "Helper.luau" + Caller->>Caller: OVERWRITES existing Helper.luau +``` + +## Root Cause + +The callers violate the `taken_names` contract. Per the original plan: `taken_names` should contain **claimed names** (bare slugs), not filenames with extensions. The defect is in what goes INTO the set, not in `deduplicate_name` itself. + +## The Fix (zero extension awareness outside file_names.rs) + +Two changes, both inside the existing architecture: + +### 1. `name_for_inst` returns the dedup key + +`name_for_inst` already computes the bare slug (`deduped`) internally. For file middleware, it appends the extension AFTER dedup to produce the filename. We expose the pre-extension slug as a third return value so callers can insert it into `taken_names`. + +``` +Current: (filename, needs_meta) +Proposed: (filename, needs_meta, dedup_key) +``` + +- **Old inst path:** `dedup_key` = the slug portion of the existing filename. `name_for_inst` already receives `middleware`, so it can strip `extension_for_middleware(middleware)` from the filename. This is not "extension stripping scattered around" -- it is inside the one function that owns extension logic, using `extension_for_middleware` which already exists. +- **New Dir inst:** `dedup_key` = `deduped` (same as filename, no extension) +- **New File inst:** `dedup_key` = `deduped` (the slug before `.{extension}` was appended) + +Callers insert `dedup_key.to_lowercase()` into `taken_names`. They never see or touch an extension. + +### 2. Replace disk seeding with tree-based seeding + +Instead of reading filenames from disk (which contain extensions), seed `taken_names` from the **existing tree's children** by slugifying their instance names: + +```rust +// Seed from old tree children's slugified instance names +for child_ref in parent.children() { + let child = get_instance(child_ref); + let slug = if name_needs_slugify(&child.name) { + slugify_name(&child.name).to_lowercase() + } else { + child.name.to_lowercase() + }; + taken_names.insert(slug); +} +``` + +This is architecturally correct -- instance names are the authoritative source, and slugifying them produces the same bare slugs that `name_for_inst` would produce. No filesystem reads, no extensions, no parsing. + +**What about orphaned files?** Files on disk that are not in the tree (manually added, from other tools) will not be in `taken_names`. A new instance could overwrite them. This is acceptable -- those files are not part of Rojo's model. The old `encode_path_name` system had the same behavior, and upstream PR #1187 does not do disk seeding either. + +## Affected Files + +### [src/syncback/file_names.rs](src/syncback/file_names.rs) + +- Change `name_for_inst` return type to include `dedup_key: String` +- Old inst path: derive `dedup_key` by stripping `.{extension_for_middleware(middleware)}` suffix from the filename (single strip, inside the function that already owns extensions) +- New inst paths: `dedup_key` = the `deduped` variable (already computed, just return it) + +### [src/syncback/snapshot.rs](src/syncback/snapshot.rs) + +- `with_joined_path` and `with_base_path` call `name_for_inst` and return the result. Update them to propagate the `dedup_key` so callers in dir.rs/project.rs can use it. + +### [src/snapshot_middleware/dir.rs](src/snapshot_middleware/dir.rs) + +- Remove `vfs.read_dir` seeding entirely +- The loop already processes old+new children interleaved; old children go through `name_for_inst`'s old-inst path, which returns the dedup_key. Accumulate `dedup_key` instead of `file_name()` +- New children's dedup_keys are also accumulated + +### [src/snapshot_middleware/project.rs](src/snapshot_middleware/project.rs) + +- Remove `vfs.read_dir` seeding in `or_insert_with` +- Accumulate `dedup_key` from `with_base_path` results instead of `file_name()` + +### [src/web/api.rs](src/web/api.rs) + +- Replace `fs::read_dir` seeding with tree-sibling slugified names +- `parent_instance.children()` gives child refs, `tree.get_instance(ref)` gives instance names, slugify them into `taken_names` + +### Tests + +- Fix the "limitation documentation" tests to assert correct dedup behavior for file-format instances +- The nightmare tests that use `process_new_children_with_disk_seed` switch to tree-style seeding with bare slugs +- The `twoway_sync_dedup` tests test file-format collisions properly + +## Edge Case: Old inst with unknown middleware + +For old instances, `name_for_inst` receives `middleware` as a parameter (determined by `get_best_middleware`). It uses `extension_for_middleware(middleware)` to derive the dedup_key. For Dir middleware, extension is empty, so dedup_key = filename. For all file middleware, the extension is known and strippable. There is no "unknown extension" case. diff --git a/.cursor/plans/nuke_path_encoding_migration_7dfffc4d.plan.md b/.cursor/plans/nuke_path_encoding_migration_7dfffc4d.plan.md index d5cab482..054f991f 100644 --- a/.cursor/plans/nuke_path_encoding_migration_7dfffc4d.plan.md +++ b/.cursor/plans/nuke_path_encoding_migration_7dfffc4d.plan.md @@ -4,25 +4,25 @@ overview: "Remove the `%NAME%` path encoding system entirely and replace it with todos: - id: phase1-infrastructure content: "Manual port of PR #1187 infrastructure: specified_name on InstanceMetadata, slugify_name(), deduplicate_name(), name field on meta file structs, apply_name() methods, json_model name field support" - status: pending + status: completed - id: phase2-replace-encode-decode content: Replace all encode_path_name/decode_path_name callsites with slugify + dedup + name property approach in snapshot middleware, syncback/file_names.rs, syncback/snapshot.rs - status: pending + status: completed - id: phase3-two-way-sync content: "Replace encoding in two-way sync: change_processor.rs renames and web/api.rs added instances, with dedup checks" - status: pending + status: completed - id: phase4-remove-config content: Remove decode_windows_invalid_chars from InstanceContext, encode_windows_invalid_chars from SyncbackRules, serve_session propagation, JSON schema - status: pending + status: completed - id: phase5-delete-module content: Delete src/path_encoding.rs and remove pub mod declaration from lib.rs - status: pending + status: completed - id: phase6-hardcore-tests content: "Hardcore test suite: unit tests for slugify/dedup, integration tests for build/syncback/two-way-sync roundtrips, collision tests, edge case tests; update existing encoded pattern tests; regenerate snapshots" - status: pending + status: completed - id: phase7-update-docs content: Update cursor rules (.cursor/rules/rojo.mdc, src.mdc) to reflect new approach - status: pending + status: completed isProject: false --- diff --git a/.cursor/rules/rojo-syncback.mdc b/.cursor/rules/rojo-syncback.mdc index 5eb54eeb..d61125a5 100644 --- a/.cursor/rules/rojo-syncback.mdc +++ b/.cursor/rules/rojo-syncback.mdc @@ -210,10 +210,12 @@ snapshot.with_new_path(path, new_ref, old_ref) ``` **Filename generation** (`file_names.rs`): -- Uses instance name +- Slugifies instance name (`slugify_name()`: forbidden chars → `_`) +- Deduplicates against taken names (`deduplicate_name()`: appends `~1`, `~2`, etc.) - Adds appropriate extension based on middleware -- Encodes Windows-invalid chars if enabled +- Stores real name in `.meta.json5` `name` field when slug differs - Example: `Script` named "My Script" → `My Script.server.luau` +- Example: `Script` named "Hey/Bro" → `Hey_Bro.server.luau` + meta with `name: "Hey/Bro"` ### Property Filtering @@ -378,7 +380,7 @@ Filesystem: - Solution: Use clean mode 2. **Invalid filename** - Instance name contains invalid chars - - Solution: Enable `decodeWindowsInvalidChars` or rename in Studio + - Solution: Handled automatically by `slugify_name()` + metadata `name` field 3. **Ref to pruned instance** - Property points to instance not in project - Logged as warning, property not synced diff --git a/.cursor/rules/rojo.mdc b/.cursor/rules/rojo.mdc index 8e5b5231..5edf5653 100644 --- a/.cursor/rules/rojo.mdc +++ b/.cursor/rules/rojo.mdc @@ -185,10 +185,11 @@ fn do_something() -> Result<()> { pub struct InstanceContext { pub path_ignore_rules: Arc>, pub sync_rules: Vec, - pub decode_windows_invalid_chars: bool, } ``` +**Filename handling:** Instance names with forbidden filesystem chars are slugified (forbidden chars → `_`), deduplicated (`~1`, `~2`, etc. on collision), and the real name is stored in `.meta.json5`/`.model.json5` `name` fields. See `src/syncback/file_names.rs`. + **VFS passed separately to snapshot functions:** ```rust snapshot_from_vfs(&context, &vfs, &path)? diff --git a/.cursor/rules/src.mdc b/.cursor/rules/src.mdc index 4c7a03d3..074e98a0 100644 --- a/.cursor/rules/src.mdc +++ b/.cursor/rules/src.mdc @@ -68,10 +68,16 @@ Transforms files → `InstanceSnapshot`. Main entry: `snapshot_from_vfs()` pub struct InstanceContext { pub path_ignore_rules: Arc>, pub sync_rules: Vec, - pub decode_windows_invalid_chars: bool, // Default: true } ``` +**Filename handling (syncback/two-way sync):** + +Instance names with forbidden filesystem chars (`<>:"/\|?*`) are handled by: +1. `slugify_name()` -- replaces forbidden chars with `_` (pure, stateless) +2. `deduplicate_name()` -- appends `~1`, `~2`, etc. on collision (stateful) +3. Metadata `name` field -- stores the real instance name in `.meta.json5`/`.model.json5` when the slug differs + ### 3. Commands (`src/cli/`) - `serve.rs` - Live sync with WebSocket diff --git a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua index 8dd78716..f804cad7 100644 --- a/plugin/src/App/Components/PatchVisualizer/DomLabel.lua +++ b/plugin/src/App/Components/PatchVisualizer/DomLabel.lua @@ -112,11 +112,10 @@ local function DiffBar(props) }), } - if removed > 0 then - local color = if props.isWhitespaceOnly then theme.Diff.WhitespaceOnly else theme.Diff.Remove - children.SegRemove = e("Frame", { - Size = UDim2.fromScale(removed / total, 1), - BackgroundColor3 = color, + if propChanges > 0 then + children.SegProp = e("Frame", { + Size = UDim2.fromScale(propChanges / total, 1), + BackgroundColor3 = theme.Diff.Property, BackgroundTransparency = props.transparency, BorderSizePixel = 0, LayoutOrder = 1, @@ -134,10 +133,11 @@ local function DiffBar(props) }) end - if propChanges > 0 then - children.SegProp = e("Frame", { - Size = UDim2.fromScale(propChanges / total, 1), - BackgroundColor3 = theme.Diff.Property, + if removed > 0 then + local color = if props.isWhitespaceOnly then theme.Diff.WhitespaceOnly else theme.Diff.Remove + children.SegRemove = e("Frame", { + Size = UDim2.fromScale(removed / total, 1), + BackgroundColor3 = color, BackgroundTransparency = props.transparency, BorderSizePixel = 0, LayoutOrder = 3, @@ -678,7 +678,7 @@ function DomLabel:render() then theme.Diff.WhitespaceOnly else theme.Diff.Remove, transparency = props.transparency, - layoutOrder = 1, + layoutOrder = 3, tooltipText = (props.changeInfo.linesRemoved or 0) .. (if (props.changeInfo.linesRemoved or 0) == 1 then " line removed" else " lines removed") .. (if props.changeInfo.isWhitespaceOnly then " (whitespace only)" else ""), @@ -707,7 +707,7 @@ function DomLabel:render() text = props.changeInfo.propChanges .. "P", color = theme.Diff.Property, transparency = props.transparency, - layoutOrder = 3, + layoutOrder = 1, tooltipText = props.changeInfo.propChanges .. (if props.changeInfo.propChanges == 1 then " property change" else " property changes") .. " (excluding Source)", diff --git a/rbx-dom b/rbx-dom index 8a8f9e79..2533e16e 160000 --- a/rbx-dom +++ b/rbx-dom @@ -1 +1 @@ -Subproject commit 8a8f9e79255a66dbb613c4a5821e6cb261dcd0f6 +Subproject commit 2533e16e026296702dddf29c0effd7ebf732a014 diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__dedup_suffix_with_meta.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__dedup_suffix_with_meta.snap new file mode 100644 index 00000000..959d0509 --- /dev/null +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__dedup_suffix_with_meta.snap @@ -0,0 +1,25 @@ +--- +source: tests/tests/build.rs +expression: contents +--- + + + + dedup_suffix_with_meta + + + + A/B + + + + + + A_B + + + + + diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__init_meta_name_override.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__init_meta_name_override.snap new file mode 100644 index 00000000..5bafeb1d --- /dev/null +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__init_meta_name_override.snap @@ -0,0 +1,18 @@ +--- +source: tests/tests/build.rs +expression: contents +--- + + + + init_meta_name_override + + + + Real:Name + + + + + diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__json_model_legacy_name.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__json_model_legacy_name.snap index ba8299cf..f395886a 100644 --- a/rojo-test/build-test-snapshots/end_to_end__tests__build__json_model_legacy_name.snap +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__json_model_legacy_name.snap @@ -1,5 +1,6 @@ --- source: tests/tests/build.rs +assertion_line: 106 expression: contents --- @@ -9,7 +10,7 @@ expression: contents - Expected Name + Overridden Name diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__meta_name_override.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__meta_name_override.snap new file mode 100644 index 00000000..6ce5ef33 --- /dev/null +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__meta_name_override.snap @@ -0,0 +1,18 @@ +--- +source: tests/tests/build.rs +expression: contents +--- + + + + meta_name_override + + + + Hey/Bro + + + + + diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__model_json_name_override.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__model_json_name_override.snap new file mode 100644 index 00000000..49c98178 --- /dev/null +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__model_json_name_override.snap @@ -0,0 +1,16 @@ +--- +source: tests/tests/build.rs +expression: contents +--- + + + + model_json_name_override + + + + Hey/Bro + + + + diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__reserved_name_override.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__reserved_name_override.snap new file mode 100644 index 00000000..e86a16a8 --- /dev/null +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__reserved_name_override.snap @@ -0,0 +1,30 @@ +--- +source: tests/tests/build.rs +expression: contents +--- + + + + reserved_name_override + + + + ReplicatedStorage + + + + CON + + + + + + PRN + + + + + + diff --git a/rojo-test/build-test-snapshots/end_to_end__tests__build__tilde_no_meta.snap b/rojo-test/build-test-snapshots/end_to_end__tests__build__tilde_no_meta.snap new file mode 100644 index 00000000..4eabf6af --- /dev/null +++ b/rojo-test/build-test-snapshots/end_to_end__tests__build__tilde_no_meta.snap @@ -0,0 +1,19 @@ +--- +source: tests/tests/build.rs +expression: contents +--- + + + + tilde_no_meta + + + + Foo~1 + + + + + diff --git a/rojo-test/build-tests/dedup_suffix_with_meta/default.project.json5 b/rojo-test/build-tests/dedup_suffix_with_meta/default.project.json5 new file mode 100644 index 00000000..8f78e37f --- /dev/null +++ b/rojo-test/build-tests/dedup_suffix_with_meta/default.project.json5 @@ -0,0 +1,6 @@ +{ + "name": "dedup_suffix_with_meta", + "tree": { + "$path": "src" + } +} diff --git a/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo.luau b/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo.luau new file mode 100644 index 00000000..6bb0d45c --- /dev/null +++ b/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo.luau @@ -0,0 +1 @@ +return "first" diff --git a/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo.meta.json5 b/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo.meta.json5 new file mode 100644 index 00000000..850bdcd2 --- /dev/null +++ b/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo.meta.json5 @@ -0,0 +1,3 @@ +{ + "name": "A/B" +} diff --git a/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo~1.luau b/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo~1.luau new file mode 100644 index 00000000..70a0843e --- /dev/null +++ b/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo~1.luau @@ -0,0 +1 @@ +return "second" diff --git a/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo~1.meta.json5 b/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo~1.meta.json5 new file mode 100644 index 00000000..b242144e --- /dev/null +++ b/rojo-test/build-tests/dedup_suffix_with_meta/src/Foo~1.meta.json5 @@ -0,0 +1,3 @@ +{ + "name": "A_B" +} diff --git a/rojo-test/build-tests/init_meta_name_override/default.project.json5 b/rojo-test/build-tests/init_meta_name_override/default.project.json5 new file mode 100644 index 00000000..73026654 --- /dev/null +++ b/rojo-test/build-tests/init_meta_name_override/default.project.json5 @@ -0,0 +1,6 @@ +{ + "name": "init_meta_name_override", + "tree": { + "$path": "src" + } +} diff --git a/rojo-test/build-tests/init_meta_name_override/src/MyFolder/init.luau b/rojo-test/build-tests/init_meta_name_override/src/MyFolder/init.luau new file mode 100644 index 00000000..9d132fdf --- /dev/null +++ b/rojo-test/build-tests/init_meta_name_override/src/MyFolder/init.luau @@ -0,0 +1 @@ +return "init module" diff --git a/rojo-test/build-tests/init_meta_name_override/src/MyFolder/init.meta.json5 b/rojo-test/build-tests/init_meta_name_override/src/MyFolder/init.meta.json5 new file mode 100644 index 00000000..2f8525dd --- /dev/null +++ b/rojo-test/build-tests/init_meta_name_override/src/MyFolder/init.meta.json5 @@ -0,0 +1,3 @@ +{ + "name": "Real:Name" +} diff --git a/rojo-test/build-tests/meta_name_override/default.project.json5 b/rojo-test/build-tests/meta_name_override/default.project.json5 new file mode 100644 index 00000000..ba216bda --- /dev/null +++ b/rojo-test/build-tests/meta_name_override/default.project.json5 @@ -0,0 +1,6 @@ +{ + "name": "meta_name_override", + "tree": { + "$path": "src" + } +} diff --git a/rojo-test/build-tests/meta_name_override/src/Hey_Bro.luau b/rojo-test/build-tests/meta_name_override/src/Hey_Bro.luau new file mode 100644 index 00000000..13fddb8b --- /dev/null +++ b/rojo-test/build-tests/meta_name_override/src/Hey_Bro.luau @@ -0,0 +1 @@ +return "module" diff --git a/rojo-test/build-tests/meta_name_override/src/Hey_Bro.meta.json5 b/rojo-test/build-tests/meta_name_override/src/Hey_Bro.meta.json5 new file mode 100644 index 00000000..578043b8 --- /dev/null +++ b/rojo-test/build-tests/meta_name_override/src/Hey_Bro.meta.json5 @@ -0,0 +1,3 @@ +{ + "name": "Hey/Bro" +} diff --git a/rojo-test/build-tests/model_json_name_override/default.project.json5 b/rojo-test/build-tests/model_json_name_override/default.project.json5 new file mode 100644 index 00000000..925cc9c6 --- /dev/null +++ b/rojo-test/build-tests/model_json_name_override/default.project.json5 @@ -0,0 +1,6 @@ +{ + "name": "model_json_name_override", + "tree": { + "$path": "src" + } +} diff --git a/rojo-test/build-tests/model_json_name_override/src/Hey_Bro.model.json5 b/rojo-test/build-tests/model_json_name_override/src/Hey_Bro.model.json5 new file mode 100644 index 00000000..fb6ec150 --- /dev/null +++ b/rojo-test/build-tests/model_json_name_override/src/Hey_Bro.model.json5 @@ -0,0 +1,4 @@ +{ + "name": "Hey/Bro", + "className": "Configuration" +} diff --git a/rojo-test/build-tests/reserved_name_override/default.project.json5 b/rojo-test/build-tests/reserved_name_override/default.project.json5 new file mode 100644 index 00000000..54ea895e --- /dev/null +++ b/rojo-test/build-tests/reserved_name_override/default.project.json5 @@ -0,0 +1,10 @@ +{ + "name": "reserved_name_override", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "$path": "src" + } + } +} diff --git a/rojo-test/build-tests/reserved_name_override/src/CON_.luau b/rojo-test/build-tests/reserved_name_override/src/CON_.luau new file mode 100644 index 00000000..aafb0926 --- /dev/null +++ b/rojo-test/build-tests/reserved_name_override/src/CON_.luau @@ -0,0 +1 @@ +return "I am CON" diff --git a/rojo-test/build-tests/reserved_name_override/src/CON_.meta.json5 b/rojo-test/build-tests/reserved_name_override/src/CON_.meta.json5 new file mode 100644 index 00000000..0a7f5e98 --- /dev/null +++ b/rojo-test/build-tests/reserved_name_override/src/CON_.meta.json5 @@ -0,0 +1,3 @@ +{ + "name": "CON" +} diff --git a/rojo-test/build-tests/reserved_name_override/src/PRN_.luau b/rojo-test/build-tests/reserved_name_override/src/PRN_.luau new file mode 100644 index 00000000..78e22614 --- /dev/null +++ b/rojo-test/build-tests/reserved_name_override/src/PRN_.luau @@ -0,0 +1 @@ +return "I am PRN" diff --git a/rojo-test/build-tests/reserved_name_override/src/PRN_.meta.json5 b/rojo-test/build-tests/reserved_name_override/src/PRN_.meta.json5 new file mode 100644 index 00000000..696bbcf8 --- /dev/null +++ b/rojo-test/build-tests/reserved_name_override/src/PRN_.meta.json5 @@ -0,0 +1,3 @@ +{ + "name": "PRN" +} diff --git a/rojo-test/build-tests/tilde_no_meta/default.project.json5 b/rojo-test/build-tests/tilde_no_meta/default.project.json5 new file mode 100644 index 00000000..4fb8f99a --- /dev/null +++ b/rojo-test/build-tests/tilde_no_meta/default.project.json5 @@ -0,0 +1,6 @@ +{ + "name": "tilde_no_meta", + "tree": { + "$path": "src" + } +} diff --git a/rojo-test/build-tests/tilde_no_meta/src/Foo~1.luau b/rojo-test/build-tests/tilde_no_meta/src/Foo~1.luau new file mode 100644 index 00000000..972b31c7 --- /dev/null +++ b/rojo-test/build-tests/tilde_no_meta/src/Foo~1.luau @@ -0,0 +1,2 @@ +-- Tilde in filename without meta should produce instance name "Foo~1" +return nil diff --git a/rojo-test/serve-tests/syncback_encoded_names/src/Key_Script.meta.json5 b/rojo-test/serve-tests/syncback_encoded_names/src/Key_Script.meta.json5 new file mode 100644 index 00000000..ae6b0920 --- /dev/null +++ b/rojo-test/serve-tests/syncback_encoded_names/src/Key_Script.meta.json5 @@ -0,0 +1,3 @@ +{ + "name": "Key:Script" +} diff --git a/rojo-test/serve-tests/syncback_encoded_names/src/Key%COLON%Script.server.luau b/rojo-test/serve-tests/syncback_encoded_names/src/Key_Script.server.luau similarity index 100% rename from rojo-test/serve-tests/syncback_encoded_names/src/Key%COLON%Script.server.luau rename to rojo-test/serve-tests/syncback_encoded_names/src/Key_Script.server.luau diff --git a/rojo-test/serve-tests/syncback_encoded_names/src/What%QUESTION%Model.model.json5 b/rojo-test/serve-tests/syncback_encoded_names/src/What%QUESTION%Model.model.json5 deleted file mode 100644 index 85e4cf30..00000000 --- a/rojo-test/serve-tests/syncback_encoded_names/src/What%QUESTION%Model.model.json5 +++ /dev/null @@ -1,6 +0,0 @@ -{ - "className": "Configuration", - "properties": { - "Name": "What?Model" - } -} diff --git a/rojo-test/serve-tests/syncback_encoded_names/src/What_Model.model.json5 b/rojo-test/serve-tests/syncback_encoded_names/src/What_Model.model.json5 new file mode 100644 index 00000000..fadde337 --- /dev/null +++ b/rojo-test/serve-tests/syncback_encoded_names/src/What_Model.model.json5 @@ -0,0 +1,4 @@ +{ + "name": "What?Model", + "className": "Configuration" +} diff --git a/rojo-test/serve-tests/syncback_encoded_names/src/What%QUESTION%Module.luau b/rojo-test/serve-tests/syncback_encoded_names/src/What_Module.luau similarity index 100% rename from rojo-test/serve-tests/syncback_encoded_names/src/What%QUESTION%Module.luau rename to rojo-test/serve-tests/syncback_encoded_names/src/What_Module.luau diff --git a/rojo-test/serve-tests/syncback_encoded_names/src/What_Module.meta.json5 b/rojo-test/serve-tests/syncback_encoded_names/src/What_Module.meta.json5 new file mode 100644 index 00000000..0ee2e845 --- /dev/null +++ b/rojo-test/serve-tests/syncback_encoded_names/src/What_Module.meta.json5 @@ -0,0 +1,3 @@ +{ + "name": "What?Module" +} diff --git a/scripts/ci.ps1 b/scripts/ci.ps1 new file mode 100644 index 00000000..4fde9c17 --- /dev/null +++ b/scripts/ci.ps1 @@ -0,0 +1,189 @@ +# Complete CI Pipeline +# Usage: .\scripts\ci.ps1 [-NoRbxDom] + +param( + [switch]$NoRbxDom +) + +$RbxDom = -not $NoRbxDom + +$ErrorActionPreference = "Continue" +$failures = @() + +function Write-Step($number, $name) { + Write-Host "" + Write-Host "=== Step ${number}: $name ===" -ForegroundColor Cyan +} + +function Record-Result($step, $exitCode) { + if ($exitCode -ne 0) { + $script:failures += $step + Write-Host "FAIL" -ForegroundColor Red + } else { + Write-Host "PASS" -ForegroundColor Green + } +} + +# ─── Rojo ──────────────────────────────────────────────────────────────────── + +Write-Step 1 "Auto-fix Lua Formatting (Stylua)" +stylua plugin/src +$styluaRojo = $LASTEXITCODE +Record-Result "Stylua (Rojo)" $styluaRojo + +Write-Step 2 "Auto-fix Rust Formatting" +cargo fmt +$rustfmt = $LASTEXITCODE +Record-Result "Rustfmt" $rustfmt + +Write-Step 3 "Auto-fix TypeScript Formatting (Prettier)" +Push-Location vscode-rojo +npx prettier --write "src/**/*.ts" +$prettier = $LASTEXITCODE +Pop-Location +Record-Result "Prettier" $prettier + +Write-Step 4 "Compile TypeScript Extension (vscode-rojo)" +Push-Location vscode-rojo +npm run compile +$tsBuild = $LASTEXITCODE +Pop-Location +Record-Result "TS Compile (vscode-rojo)" $tsBuild + +Write-Step 5 "Lua Static Analysis (Selene)" +selene plugin/src +$selene = $LASTEXITCODE +Record-Result "Selene" $selene + +Write-Step 6 "Rust Linting (Clippy) - Auto-fix" +cargo clippy --fix --allow-dirty --allow-staged 2>&1 | Out-Null +Write-Host "Verifying..." -ForegroundColor Yellow +cargo clippy --all-targets --all-features 2>&1 +$clippy = $LASTEXITCODE +Record-Result "Clippy" $clippy + +Write-Step 7 "Build Everything" +cargo build --locked --all-targets --all-features +$build = $LASTEXITCODE +Record-Result "Build" $build + +Write-Step 8 "Run ALL Rust Tests" +$testOutput = cargo test --locked --all-targets --all-features 2>&1 +$rustTests = $LASTEXITCODE +$testOutput | Write-Host +Record-Result "Rust Tests" $rustTests + +Write-Step 9 "Run Roblox Plugin Tests" +.\target\debug\atlas.exe build plugin/test-place.project.json -o TestPlace.rbxl +if ($LASTEXITCODE -eq 0) { + run-in-roblox --script plugin/run-tests.server.lua --place TestPlace.rbxl + $pluginTests = $LASTEXITCODE +} else { + Write-Host "Skipped: build failed" -ForegroundColor Yellow + $pluginTests = 1 +} +Record-Result "Plugin Tests" $pluginTests + +# ─── rbx-dom ───────────────────────────────────────────────────────────────── + +if ($RbxDom) { + Write-Step 10 "Auto-fix Lua Formatting (Stylua) for rbx_dom_lua" + stylua rbx-dom/rbx_dom_lua/src + $styluaRbxDom = $LASTEXITCODE + Record-Result "Stylua (rbx_dom_lua)" $styluaRbxDom + + Write-Step 11 "Auto-fix Rust Formatting (rbx-dom)" + Push-Location rbx-dom + cargo fmt + $rustfmtRbxDom = $LASTEXITCODE + Pop-Location + Record-Result "Rustfmt (rbx-dom)" $rustfmtRbxDom + + Write-Step 12 "Lua Static Analysis (Selene) for rbx_dom_lua" + selene rbx-dom/rbx_dom_lua/src + $seleneRbxDom = $LASTEXITCODE + Record-Result "Selene (rbx_dom_lua)" $seleneRbxDom + + Write-Step 13 "Rust Linting (Clippy) for rbx-dom - Auto-fix" + Push-Location rbx-dom + cargo clippy --fix --allow-dirty --allow-staged 2>&1 | Out-Null + Write-Host "Verifying..." -ForegroundColor Yellow + cargo clippy --all-targets --all-features -- -D warnings 2>&1 + $clippyRbxDom = $LASTEXITCODE + Pop-Location + Record-Result "Clippy (rbx-dom)" $clippyRbxDom + + Write-Step 14 "Build rbx-dom" + Push-Location rbx-dom + cargo build --verbose + $buildRbxDom = $LASTEXITCODE + cargo build --all-features --verbose + $buildRbxDomAll = $LASTEXITCODE + Pop-Location + Record-Result "Build (rbx-dom)" $buildRbxDom + Record-Result "Build (rbx-dom all-features)" $buildRbxDomAll + + Write-Step 15 "Run ALL rbx-dom Rust Tests" + Push-Location rbx-dom + cargo test --verbose + $testsRbxDom = $LASTEXITCODE + cargo test --all-features --verbose + $testsRbxDomAll = $LASTEXITCODE + Pop-Location + Record-Result "Tests (rbx-dom)" $testsRbxDom + Record-Result "Tests (rbx-dom all-features)" $testsRbxDomAll +} + +# ─── Report ────────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "=== CI COMPLETE ===" -ForegroundColor Magenta +Write-Host "" +Write-Host "--- Rojo ---" -ForegroundColor White +Write-Host "" +Write-Host "Formatting:" +Write-Host " - Stylua: $(if ($styluaRojo -eq 0) {'PASS'} else {'FAIL'})" +Write-Host " - Rustfmt: $(if ($rustfmt -eq 0) {'PASS'} else {'FAIL'})" +Write-Host " - Prettier (vscode-rojo): $(if ($prettier -eq 0) {'PASS'} else {'FAIL'})" +Write-Host "" +Write-Host "TS Compile (vscode-rojo): $(if ($tsBuild -eq 0) {'PASS'} else {'FAIL'})" +Write-Host "" +Write-Host "Linting:" +Write-Host " - Selene: $(if ($selene -eq 0) {'PASS'} else {'FAIL'})" +Write-Host " - Clippy: $(if ($clippy -eq 0) {'PASS'} else {'FAIL'})" +Write-Host "" +Write-Host "Build: $(if ($build -eq 0) {'PASS'} else {'FAIL'})" +Write-Host "" +Write-Host "Tests:" +Write-Host " - Rust: $(if ($rustTests -eq 0) {'PASS'} else {'FAIL'})" +Write-Host " - Plugin: $(if ($pluginTests -eq 0) {'PASS'} else {'FAIL'})" + +if ($RbxDom) { + Write-Host "" + Write-Host "--- rbx-dom ---" -ForegroundColor White + Write-Host "" + Write-Host "Formatting:" + Write-Host " - Stylua (rbx_dom_lua): $(if ($styluaRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host " - Rustfmt: $(if ($rustfmtRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host "" + Write-Host "Linting:" + Write-Host " - Selene (rbx_dom_lua): $(if ($seleneRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host " - Clippy: $(if ($clippyRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host "" + Write-Host "Build: $(if ($buildRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host "Build (all features): $(if ($buildRbxDomAll -eq 0) {'PASS'} else {'FAIL'})" + Write-Host "" + Write-Host "Tests:" + Write-Host " - Rust: $(if ($testsRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host " - Rust (all features): $(if ($testsRbxDomAll -eq 0) {'PASS'} else {'FAIL'})" +} + +Write-Host "" +if ($failures.Count -eq 0) { + Write-Host "--- Overall: PASS ---" -ForegroundColor Green + exit 0 +} else { + Write-Host "--- Overall: FAIL ---" -ForegroundColor Red + Write-Host "Failed steps: $($failures -join ', ')" -ForegroundColor Red + exit 1 +} diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100644 index 00000000..e3a2cf12 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Complete CI Pipeline +# Usage: bash scripts/ci.sh [--no-rbx-dom] + +set -o pipefail + +RBX_DOM=true +for arg in "$@"; do + case "$arg" in + --no-rbx-dom) RBX_DOM=false ;; + esac +done + +failures=() + +step() { + echo "" + echo "=== Step $1: $2 ===" +} + +record() { + if [ "$2" -ne 0 ]; then + failures+=("$1") + echo "FAIL" + else + echo "PASS" + fi +} + +# ─── Rojo ──────────────────────────────────────────────────────────────────── + +step 1 "Auto-fix Lua Formatting (Stylua)" +stylua plugin/src +stylua_rojo=$? +record "Stylua (Rojo)" $stylua_rojo + +step 2 "Auto-fix Rust Formatting" +cargo fmt +rustfmt_exit=$? +record "Rustfmt" $rustfmt_exit + +step 3 "Auto-fix TypeScript Formatting (Prettier)" +(cd vscode-rojo && npx prettier --write "src/**/*.ts") +prettier_exit=$? +record "Prettier" $prettier_exit + +step 4 "Compile TypeScript Extension (vscode-rojo)" +(cd vscode-rojo && npm run compile) +ts_build=$? +record "TS Compile (vscode-rojo)" $ts_build + +step 5 "Lua Static Analysis (Selene)" +selene plugin/src +selene_exit=$? +record "Selene" $selene_exit + +step 6 "Rust Linting (Clippy) - Auto-fix" +cargo clippy --fix --allow-dirty --allow-staged >/dev/null 2>&1 +echo "Verifying..." +cargo clippy --all-targets --all-features 2>&1 +clippy_exit=$? +record "Clippy" $clippy_exit + +step 7 "Build Everything" +cargo build --locked --all-targets --all-features +build_exit=$? +record "Build" $build_exit + +step 8 "Run ALL Rust Tests" +cargo test --locked --all-targets --all-features 2>&1 +rust_tests=$? +record "Rust Tests" $rust_tests + +step 9 "Run Roblox Plugin Tests" +./target/debug/atlas build plugin/test-place.project.json -o TestPlace.rbxl +if [ $? -eq 0 ]; then + run-in-roblox --script plugin/run-tests.server.lua --place TestPlace.rbxl + plugin_tests=$? +else + echo "Skipped: build failed" + plugin_tests=1 +fi +record "Plugin Tests" $plugin_tests + +# ─── rbx-dom ───────────────────────────────────────────────────────────────── + +if [ "$RBX_DOM" = true ]; then + step 10 "Auto-fix Lua Formatting (Stylua) for rbx_dom_lua" + (cd rbx-dom && stylua rbx_dom_lua/src) + stylua_rbxdom=$? + record "Stylua (rbx_dom_lua)" $stylua_rbxdom + + step 11 "Auto-fix Rust Formatting (rbx-dom)" + (cd rbx-dom && cargo fmt) + rustfmt_rbxdom=$? + record "Rustfmt (rbx-dom)" $rustfmt_rbxdom + + step 12 "Lua Static Analysis (Selene) for rbx_dom_lua" + (cd rbx-dom && selene rbx_dom_lua/src) + selene_rbxdom=$? + record "Selene (rbx_dom_lua)" $selene_rbxdom + + step 13 "Rust Linting (Clippy) for rbx-dom - Auto-fix" + (cd rbx-dom && cargo clippy --fix --allow-dirty --allow-staged >/dev/null 2>&1) + echo "Verifying..." + (cd rbx-dom && cargo clippy --all-targets --all-features -- -D warnings 2>&1) + clippy_rbxdom=$? + record "Clippy (rbx-dom)" $clippy_rbxdom + + step 14 "Build rbx-dom" + (cd rbx-dom && cargo build --verbose) + build_rbxdom=$? + (cd rbx-dom && cargo build --all-features --verbose) + build_rbxdom_all=$? + record "Build (rbx-dom)" $build_rbxdom + record "Build (rbx-dom all-features)" $build_rbxdom_all + + step 15 "Run ALL rbx-dom Rust Tests" + (cd rbx-dom && cargo test --verbose) + tests_rbxdom=$? + (cd rbx-dom && cargo test --all-features --verbose) + tests_rbxdom_all=$? + record "Tests (rbx-dom)" $tests_rbxdom + record "Tests (rbx-dom all-features)" $tests_rbxdom_all +fi + +# ─── Report ────────────────────────────────────────────────────────────────── + +pass_or_fail() { [ "$1" -eq 0 ] && echo "PASS" || echo "FAIL"; } + +echo "" +echo "=== CI COMPLETE ===" +echo "" +echo "--- Rojo ---" +echo "" +echo "Formatting:" +echo " - Stylua: $(pass_or_fail $stylua_rojo)" +echo " - Rustfmt: $(pass_or_fail $rustfmt_exit)" +echo " - Prettier (vscode-rojo): $(pass_or_fail $prettier_exit)" +echo "" +echo "TS Compile (vscode-rojo): $(pass_or_fail $ts_build)" +echo "" +echo "Linting:" +echo " - Selene: $(pass_or_fail $selene_exit)" +echo " - Clippy: $(pass_or_fail $clippy_exit)" +echo "" +echo "Build: $(pass_or_fail $build_exit)" +echo "" +echo "Tests:" +echo " - Rust: $(pass_or_fail $rust_tests)" +echo " - Plugin: $(pass_or_fail $plugin_tests)" + +if [ "$RBX_DOM" = true ]; then + echo "" + echo "--- rbx-dom ---" + echo "" + echo "Formatting:" + echo " - Stylua (rbx_dom_lua): $(pass_or_fail $stylua_rbxdom)" + echo " - Rustfmt: $(pass_or_fail $rustfmt_rbxdom)" + echo "" + echo "Linting:" + echo " - Selene (rbx_dom_lua): $(pass_or_fail $selene_rbxdom)" + echo " - Clippy: $(pass_or_fail $clippy_rbxdom)" + echo "" + echo "Build: $(pass_or_fail $build_rbxdom)" + echo "Build (all features): $(pass_or_fail $build_rbxdom_all)" + echo "" + echo "Tests:" + echo " - Rust: $(pass_or_fail $tests_rbxdom)" + echo " - Rust (all features): $(pass_or_fail $tests_rbxdom_all)" +fi + +echo "" +if [ ${#failures[@]} -eq 0 ]; then + echo "--- Overall: PASS ---" + exit 0 +else + echo "--- Overall: FAIL ---" + echo "Failed steps: $(IFS=', '; echo "${failures[*]}")" + exit 1 +fi diff --git a/src/change_processor.rs b/src/change_processor.rs index 0daae4e7..a71d294b 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -11,11 +11,11 @@ use std::{ use crate::{ message_queue::MessageQueue, - path_encoding::encode_path_name, snapshot::{ apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree, }, snapshot_middleware::{snapshot_from_vfs, snapshot_project_node}, + syncback::{deduplicate_name, name_needs_slugify, slugify_name, strip_script_suffix}, }; /// Processes file change events, updates the DOM, and sends those updates @@ -226,6 +226,101 @@ impl JobThreadContext { } } + /// Suppress the next Remove VFS event for the given path. + fn suppress_path_remove(&self, path: &Path) { + let mut suppressed = self.suppressed_paths.lock().unwrap(); + let key = Self::suppression_key(path); + suppressed.entry(key).or_insert((0, 0)).0 += 1; + } + + /// Upsert the `name` field in a `.meta.json5` file, suppressing filesystem + /// events to avoid feedback loops. + fn upsert_meta_name_field(&self, meta_path: &Path, real_name: &str) { + self.suppress_path(meta_path); + if let Err(err) = crate::syncback::meta::upsert_meta_name(meta_path, real_name) { + self.unsuppress_path(meta_path); + log::error!( + "Failed to upsert name in meta file {}: {}", + meta_path.display(), + err + ); + } + } + + /// 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) { + use crate::syncback::meta::RemoveNameOutcome; + // Suppress for the write/delete that may follow + self.suppress_path(meta_path); + match crate::syncback::meta::remove_meta_name(meta_path) { + Ok(RemoveNameOutcome::NoOp) => { + self.unsuppress_path(meta_path); + } + Ok(RemoveNameOutcome::FileDeleted) => { + // File was deleted, not rewritten. Swap: undo the + // pre-emptive Write suppression and add a Remove + // suppression instead so the counts are (1, 0). + self.unsuppress_path(meta_path); + self.suppress_path_remove(meta_path); + } + Ok(RemoveNameOutcome::FieldRemoved) => { + // File was rewritten — suppress_path already covers it. + } + Err(err) => { + self.unsuppress_path(meta_path); + log::error!( + "Failed to remove name from meta file {}: {}", + meta_path.display(), + err + ); + } + } + } + /// Computes and applies patches to the DOM for a given file path. /// /// This function finds the nearest ancestor to the given path that has associated instances @@ -561,47 +656,27 @@ impl JobThreadContext { let applied_patch = { let mut tree = self.tree.lock().unwrap(); + // NOTE: We do NOT delete files from disk here. The API handler + // (handle_api_write → remove_instance_at_path) already deleted + // the files before sending this PatchSet. Re-deleting would + // destroy any new file created at the same path — e.g., when an + // instance is renamed from "joe_test" to "joe/test", the new slug + // is also "joe_test", so the API creates a new file at the same + // path that the removal just deleted. If we deleted again here, + // the new file would be destroyed. + // + // The PatchSet removal only needs to update the DOM tree (handled + // by apply_patch_set below). for &id in &patch_set.removed_instances { if let Some(instance) = tree.get_instance(id) { if let Some(instigating_source) = &instance.metadata().instigating_source { match instigating_source { InstigatingSource::Path(path) => { - // Guard: file may already be deleted by the API's - // syncback_removed_instance before this PatchSet arrives. - if path.exists() { - if path.is_dir() { - log::info!( - "Two-way sync: Removing directory {}", - path.display() - ); - if let Err(err) = fs::remove_dir_all(path) { - log::error!( - "Failed to remove directory {:?} for instance {:?}: {}", - path, - id, - err - ); - } - } else { - log::info!( - "Two-way sync: Removing file {}", - path.display() - ); - if let Err(err) = fs::remove_file(path) { - log::error!( - "Failed to remove file {:?} for instance {:?}: {}", - path, - id, - err - ); - } - } - } else { - log::info!( - "Two-way sync: File already removed: {}", - path.display() - ); - } + log::info!( + "Two-way sync: Removing instance {:?} from tree (path: {})", + id, + path.display() + ); } InstigatingSource::ProjectNode { .. } => { log::warn!( @@ -653,17 +728,27 @@ impl JobThreadContext { if is_init_file { let dir_path = path.parent().unwrap(); if let Some(grandparent) = dir_path.parent() { - // Use the encoded dir name from the filesystem - // for the old meta lookup, and encode the new - // name for the new path. Using instance.name() - // (decoded) would fail for encoded names. let dir_name = dir_path .file_name() .and_then(|f| f.to_str()) .unwrap_or(""); - let encoded_new_name = encode_path_name(new_name); + // Slugify the new name for filesystem safety + let slugified_new_name = + if name_needs_slugify(new_name) { + slugify_name(new_name) + } else { + new_name.to_string() + }; + // Directory renames: fs::rename fails safely + // on all platforms if the target is a non-empty + // directory, so no dedup is needed here (unlike + // the file rename path below, where Unix + // fs::rename silently overwrites). let new_dir_path = - grandparent.join(&encoded_new_name); + grandparent.join(&slugified_new_name); + + // Track the effective directory path after any rename + let effective_dir_path; if new_dir_path != dir_path { log::info!( @@ -684,6 +769,7 @@ impl JobThreadContext { new_dir_path, err ); + effective_dir_path = dir_path.to_path_buf(); } else { // The init file moved with the directory. overridden_source_path = @@ -696,7 +782,7 @@ impl JobThreadContext { let new_meta = grandparent.join(format!( "{}.meta.json5", - encoded_new_name + slugified_new_name )); self.suppress_path_any(&old_meta); self.suppress_path(&new_meta); @@ -707,14 +793,31 @@ impl JobThreadContext { self.unsuppress_path(&new_meta); } } + effective_dir_path = new_dir_path.clone(); } + } else { + effective_dir_path = dir_path.to_path_buf(); + } + + // Always update the meta name field when the + // instance name changed, even if the directory + // path didn't change (e.g. "Foo/Bar" → "Foo|Bar" + // both slugify to "Foo_Bar"). + let init_meta = + effective_dir_path.join("init.meta.json5"); + if slugified_new_name != *new_name { + self.upsert_meta_name_field( + &init_meta, new_name, + ); + } else { + self.remove_meta_name_field(&init_meta); } } } else if let Some(parent) = path.parent() { // Derive the suffix from the existing filename - // rather than using replacen with the decoded - // instance name, which fails for encoded names - // (e.g., What%QUESTION%.server.luau). + // rather than using replacen with the instance + // name, which may differ from the filesystem + // name (e.g., slugified names). let extension = path .extension() .and_then(|e| e.to_str()) @@ -725,7 +828,7 @@ impl JobThreadContext { .unwrap_or(""); let known_suffixes = [ ".server", ".client", ".plugin", ".local", - ".legacy", + ".legacy", ".model", ]; let script_suffix = known_suffixes .iter() @@ -738,17 +841,79 @@ impl JobThreadContext { &stem[..stem.len() - script_suffix.len()] }; - let encoded_new_name = encode_path_name(new_name); + let slugified_new_name = if name_needs_slugify(new_name) + { + slugify_name(new_name) + } else { + new_name.to_string() + }; + + // Guard against rename collision: if the target + // path already exists and isn't our own file, + // deduplicate the name against siblings. + let deduped_new_name; + { + let candidate_file = if extension.is_empty() { + format!( + "{}{}", + slugified_new_name, script_suffix + ) + } else { + format!( + "{}{}.{}", + slugified_new_name, + script_suffix, + extension + ) + }; + let candidate = parent.join(&candidate_file); + if candidate != *path && candidate.exists() { + let mut taken: std::collections::HashSet< + String, + > = std::collections::HashSet::new(); + if let Ok(entries) = fs::read_dir(parent) { + for entry in entries.flatten() { + let ep = entry.path(); + let slug = if ep.is_dir() { + ep.file_name() + .and_then(|f| f.to_str()) + .unwrap_or("") + .to_lowercase() + } else { + let s = ep + .file_stem() + .and_then(|f| f.to_str()) + .unwrap_or(""); + strip_script_suffix(s) + .to_lowercase() + }; + taken.insert(slug); + } + } + // Free the slot we're vacating + taken.remove(&old_base.to_lowercase()); + deduped_new_name = deduplicate_name( + &slugified_new_name, + &taken, + ); + } else { + deduped_new_name = slugified_new_name.clone(); + } + } + let new_file_name = if extension.is_empty() { - format!("{}{}", encoded_new_name, script_suffix) + format!("{}{}", deduped_new_name, script_suffix) } else { format!( "{}{}.{}", - encoded_new_name, script_suffix, extension + deduped_new_name, script_suffix, extension ) }; let new_path = parent.join(&new_file_name); + // Track the effective meta base name after any rename + let effective_meta_base: &str; + if new_path != *path { log::info!( "Two-way sync: Renaming {} -> {}", @@ -766,15 +931,16 @@ impl JobThreadContext { new_path, err ); + effective_meta_base = old_base; } else { overridden_source_path = Some(new_path.clone()); let old_meta = parent .join(format!("{}.meta.json5", old_base)); + let new_meta = parent.join(format!( + "{}.meta.json5", + deduped_new_name + )); if old_meta.exists() { - let new_meta = parent.join(format!( - "{}.meta.json5", - encoded_new_name - )); self.suppress_path_any(&old_meta); self.suppress_path(&new_meta); if fs::rename(&old_meta, &new_meta).is_err() @@ -783,6 +949,44 @@ impl JobThreadContext { self.unsuppress_path(&new_meta); } } + effective_meta_base = &deduped_new_name; + } + } else { + effective_meta_base = old_base; + } + + // 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 + )); + if deduped_new_name != *new_name { + self.upsert_meta_name_field( + ¤t_meta, + new_name, + ); + } else { + self.remove_meta_name_field(¤t_meta); } } } @@ -861,10 +1065,9 @@ impl JobThreadContext { format!("init{}.luau", new_suffix) } } else { - // Derive the encoded base name from the - // filesystem path, not instance.name() - // (which is decoded). This preserves - // encoding like What%QUESTION% on disk. + // Derive the base name from the filesystem + // path, not instance.name() (which may + // differ from the slugified filename). let stem = actual_file .file_stem() .and_then(|s| s.to_str()) diff --git a/src/lib.rs b/src/lib.rs index 889de19e..549fa1d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,6 @@ mod json; mod lua_ast; mod message_queue; mod multimap; -pub mod path_encoding; mod path_serializer; mod project; mod resolution; diff --git a/src/path_encoding.rs b/src/path_encoding.rs deleted file mode 100644 index 291da1fa..00000000 --- a/src/path_encoding.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! Encoding and decoding utilities for special filename characters. -//! -//! This module encodes characters that are problematic in file names: -//! - Windows-invalid characters: < > : " / \ | ? * -//! - Periods (.) to prevent conflicts with file extension parsing -//! - Leading/trailing spaces -//! - Literal percent signs (%) to allow round-tripping -//! -//! Characters are encoded using a %NAME% format (e.g., `<` becomes `%LT%`, -//! `.` becomes `%DOT%`). Literal `%` is escaped as `%%` (like printf). - -use std::collections::HashMap; -use std::sync::OnceLock; - -/// Mapping of special characters to their encoded representations. -const CHAR_ENCODINGS: &[(&str, &str)] = &[ - (".", "%DOT%"), - ("<", "%LT%"), - (">", "%GT%"), - (":", "%COLON%"), - ("\"", "%QUOTE%"), - ("/", "%SLASH%"), - ("\\", "%BACKSLASH%"), - ("|", "%PIPE%"), - ("?", "%QUESTION%"), - ("*", "%STAR%"), -]; - -/// Returns a map from encoded pattern (e.g., "DOT") to decoded char (e.g., "."). -fn get_decode_map() -> &'static HashMap<&'static str, &'static str> { - static DECODE_MAP: OnceLock> = OnceLock::new(); - DECODE_MAP.get_or_init(|| { - let mut map = HashMap::new(); - for (char, encoded) in CHAR_ENCODINGS { - // Strip the leading and trailing % from the pattern - let pattern = &encoded[1..encoded.len() - 1]; - map.insert(pattern, *char); - } - map - }) -} - -const SPACE_ENCODING: &str = "%SPACE%"; - -/// Encodes special characters in a file name. -/// -/// This replaces characters like `.`, `<`, `>`, `:`, `"`, `/`, `\`, `|`, `?`, `*` -/// with their encoded representations like `%DOT%`, `%LT%`, `%GT%`, etc. -/// Literal `%` is escaped as `%%`. Leading and trailing spaces are encoded as `%SPACE%`. -pub fn encode_path_name(name: &str) -> String { - let trimmed = name.trim_matches(' '); - - // Handle the case where the string is entirely spaces (or empty) - if trimmed.is_empty() { - return SPACE_ENCODING.repeat(name.len()); - } - - // Count leading and trailing spaces - let leading_spaces = name.len() - name.trim_start_matches(' ').len(); - let trailing_spaces = name.len() - name.trim_end_matches(' ').len(); - - // Get the middle part (without leading/trailing spaces) - let middle = &name[leading_spaces..name.len() - trailing_spaces]; - - // First: escape % as %% (before adding new % signs from encodings) - let mut encoded_middle = middle.replace('%', "%%"); - - // Then: encode special characters - for (char, encoded) in CHAR_ENCODINGS { - encoded_middle = encoded_middle.replace(char, encoded); - } - - // Build final result with encoded spaces - let encoded_prefix = SPACE_ENCODING.repeat(leading_spaces); - let encoded_suffix = SPACE_ENCODING.repeat(trailing_spaces); - - format!("{}{}{}", encoded_prefix, encoded_middle, encoded_suffix) -} - -/// Decodes encoded special characters back to their original form. -/// -/// This replaces encoded representations like `%DOT%`, `%LT%`, `%GT%`, etc. -/// back to their original characters `.`, `<`, `>`, etc. -/// `%%` is decoded to literal `%`. `%SPACE%` at the start/end is decoded back to spaces. -/// -/// Uses a proper left-to-right parser to correctly handle `%%` escaping. -pub fn decode_path_name(name: &str) -> String { - let mut result = name.to_string(); - - // Count and strip leading %SPACE% - let mut leading_spaces = 0; - while result.starts_with(SPACE_ENCODING) { - leading_spaces += 1; - result = result[SPACE_ENCODING.len()..].to_string(); - } - - // Count and strip trailing %SPACE% - let mut trailing_spaces = 0; - while result.ends_with(SPACE_ENCODING) { - trailing_spaces += 1; - result = result[..result.len() - SPACE_ENCODING.len()].to_string(); - } - - // Parse and decode the middle part - let decoded_middle = decode_patterns(&result); - - // Rebuild with actual spaces - let prefix = " ".repeat(leading_spaces); - let suffix = " ".repeat(trailing_spaces); - format!("{}{}{}", prefix, decoded_middle, suffix) -} - -/// Parses an encoded string left-to-right, properly handling `%%` escapes and `%NAME%` patterns. -fn decode_patterns(input: &str) -> String { - let decode_map = get_decode_map(); - let chars: Vec = input.chars().collect(); - let mut output = String::with_capacity(input.len()); - let mut i = 0; - - while i < chars.len() { - if chars[i] == '%' { - // Check for %% (escaped percent) - if i + 1 < chars.len() && chars[i + 1] == '%' { - output.push('%'); - i += 2; - continue; - } - - // Try to match a %NAME% pattern - if let Some((decoded, consumed)) = try_decode_pattern(&chars, i, decode_map) { - output.push_str(&decoded); - i += consumed; - continue; - } - } - - // Regular character, just copy it - output.push(chars[i]); - i += 1; - } - - output -} - -/// Tries to decode a %NAME% pattern starting at position `start`. -/// Returns Some((decoded_string, chars_consumed)) if successful, None otherwise. -fn try_decode_pattern( - chars: &[char], - start: usize, - decode_map: &HashMap<&str, &str>, -) -> Option<(String, usize)> { - // Must start with % - if chars.get(start) != Some(&'%') { - return None; - } - - // Find the closing % - let mut end = start + 1; - while end < chars.len() && chars[end] != '%' { - // Pattern names are uppercase letters only - if !chars[end].is_ascii_uppercase() { - return None; - } - end += 1; - } - - // Must have found a closing % - if end >= chars.len() || chars[end] != '%' { - return None; - } - - // Extract the pattern name (without the % delimiters) - let pattern_name: String = chars[start + 1..end].iter().collect(); - - // Look up the pattern - if let Some(&decoded) = decode_map.get(pattern_name.as_str()) { - Some((decoded.to_string(), end - start + 1)) - } else { - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_encode_all_chars() { - let input = r#".<>:"/\|?*"#; - let expected = "%DOT%%LT%%GT%%COLON%%QUOTE%%SLASH%%BACKSLASH%%PIPE%%QUESTION%%STAR%"; - assert_eq!(encode_path_name(input), expected); - } - - #[test] - fn test_decode_all_chars() { - let input = "%DOT%%LT%%GT%%COLON%%QUOTE%%SLASH%%BACKSLASH%%PIPE%%QUESTION%%STAR%"; - let expected = r#".<>:"/\|?*"#; - assert_eq!(decode_path_name(input), expected); - } - - #[test] - fn test_roundtrip() { - let original = "TestWith:Special\"Chars/And\\More|Stuff?Here*End"; - let encoded = encode_path_name(original); - let decoded = decode_path_name(&encoded); - assert_eq!(decoded, original); - } - - #[test] - fn test_no_special_chars() { - let input = "NormalFileName"; - assert_eq!(encode_path_name(input), input); - assert_eq!(decode_path_name(input), input); - } - - #[test] - fn test_leading_spaces() { - let input = " LeadingSpaces"; - let encoded = encode_path_name(input); - assert_eq!(encoded, "%SPACE%%SPACE%LeadingSpaces"); - assert_eq!(decode_path_name(&encoded), input); - } - - #[test] - fn test_trailing_spaces() { - let input = "TrailingSpaces "; - let encoded = encode_path_name(input); - assert_eq!(encoded, "TrailingSpaces%SPACE%%SPACE%"); - assert_eq!(decode_path_name(&encoded), input); - } - - #[test] - fn test_both_spaces() { - let input = " Both Spaces "; - let encoded = encode_path_name(input); - assert_eq!(encoded, "%SPACE%Both Spaces%SPACE%"); - assert_eq!(decode_path_name(&encoded), input); - } - - #[test] - fn test_middle_spaces_unchanged() { - let input = "Middle Spaces Here"; - let encoded = encode_path_name(input); - assert_eq!(encoded, "Middle Spaces Here"); - assert_eq!(decode_path_name(&encoded), input); - } - - #[test] - fn test_roundtrip_with_spaces() { - let original = " "; - let encoded = encode_path_name(original); - let decoded = decode_path_name(&encoded); - assert_eq!(decoded, original); - } - - #[test] - fn test_encode_period() { - let input = "My.Script"; - let expected = "My%DOT%Script"; - assert_eq!(encode_path_name(input), expected); - } - - #[test] - fn test_decode_period() { - let input = "My%DOT%Script"; - let expected = "My.Script"; - assert_eq!(decode_path_name(input), expected); - } - - #[test] - fn test_roundtrip_with_period() { - let original = "My.Module.Name"; - let encoded = encode_path_name(original); - let decoded = decode_path_name(&encoded); - assert_eq!(decoded, original); - } - - #[test] - fn test_encode_percent() { - let input = "My%Thing"; - let expected = "My%%Thing"; - assert_eq!(encode_path_name(input), expected); - } - - #[test] - fn test_decode_percent() { - let input = "My%%Thing"; - let expected = "My%Thing"; - assert_eq!(decode_path_name(input), expected); - } - - #[test] - fn test_roundtrip_with_percent() { - let original = "My%Thing"; - let encoded = encode_path_name(original); - let decoded = decode_path_name(&encoded); - assert_eq!(decoded, original); - } - - #[test] - fn test_percent_encoding_pattern_roundtrip() { - // This tests that a name containing what looks like an encoding pattern - // round-trips correctly and doesn't get incorrectly decoded - let original = "My%DOT%Thing"; - let encoded = encode_path_name(original); - assert_eq!(encoded, "My%%DOT%%Thing"); - let decoded = decode_path_name(&encoded); - assert_eq!(decoded, original); - } - - #[test] - fn test_combined_period_and_percent() { - let original = "My.%Thing.Other"; - let encoded = encode_path_name(original); - let decoded = decode_path_name(&encoded); - assert_eq!(decoded, original); - } - - #[test] - fn test_single_space() { - let input = " "; - let encoded = encode_path_name(input); - assert_eq!(encoded, "%SPACE%"); - assert_eq!(decode_path_name(&encoded), input); - } - - #[test] - fn test_multiple_spaces_only() { - let input = " "; - let encoded = encode_path_name(input); - assert_eq!(encoded, "%SPACE%%SPACE%%SPACE%"); - assert_eq!(decode_path_name(&encoded), input); - } - - #[test] - fn test_empty_string() { - let input = ""; - let encoded = encode_path_name(input); - assert_eq!(encoded, ""); - assert_eq!(decode_path_name(&encoded), input); - } -} diff --git a/src/serve_session.rs b/src/serve_session.rs index 04a0520f..12d7cc6e 100644 --- a/src/serve_session.rs +++ b/src/serve_session.rs @@ -114,14 +114,7 @@ impl ServeSession { let root_id = tree.get_root_id(); - let decode_windows_invalid_chars = root_project - .syncback_rules - .as_ref() - .map(|rules| rules.encode_windows_invalid_chars()) - .unwrap_or(true); - - let mut instance_context = InstanceContext::new(); - instance_context.set_decode_windows_invalid_chars(decode_windows_invalid_chars); + let instance_context = InstanceContext::new(); log::trace!("Generating snapshot of instances from VFS"); let snapshot = snapshot_from_vfs(&instance_context, &vfs, start_path)?; diff --git a/src/snapshot/metadata.rs b/src/snapshot/metadata.rs index bd2c222b..132769eb 100644 --- a/src/snapshot/metadata.rs +++ b/src/snapshot/metadata.rs @@ -66,6 +66,12 @@ pub struct InstanceMetadata { /// A schema provided via a JSON file, if one exists. Will be `None` for /// all non-JSON middleware. pub schema: Option, + + /// A custom name specified via meta.json or model.json files. If present, + /// this name will be used for the instance while the filesystem name will + /// be slugified to remove illegal characters. + #[serde(skip_serializing_if = "Option::is_none")] + pub specified_name: Option, } impl InstanceMetadata { @@ -78,6 +84,7 @@ impl InstanceMetadata { specified_id: None, middleware: None, schema: None, + specified_name: None, } } @@ -126,6 +133,13 @@ impl InstanceMetadata { pub fn schema(self, schema: Option) -> Self { Self { schema, ..self } } + + pub fn specified_name(self, specified_name: Option) -> Self { + Self { + specified_name, + ..self + } + } } impl Default for InstanceMetadata { @@ -140,16 +154,6 @@ pub struct InstanceContext { pub path_ignore_rules: Arc>, #[serde(skip_serializing_if = "Vec::is_empty")] pub sync_rules: Vec, - /// Whether to decode special characters in file names. - /// Characters like `%DOT%`, `%LT%`, `%GT%`, `%COLON%`, etc. will be decoded - /// back to `.`, `<`, `>`, `:`, etc. in instance names sent to the plugin. - /// Defaults to true. - #[serde(default = "decode_windows_invalid_chars_default")] - pub decode_windows_invalid_chars: bool, -} - -fn decode_windows_invalid_chars_default() -> bool { - true } impl InstanceContext { @@ -157,14 +161,9 @@ impl InstanceContext { Self { path_ignore_rules: Arc::new(Vec::new()), sync_rules: Vec::new(), - decode_windows_invalid_chars: true, } } - pub fn set_decode_windows_invalid_chars(&mut self, decode: bool) { - self.decode_windows_invalid_chars = decode; - } - /// Extend the list of ignore rules in the context with the given new rules. pub fn add_path_ignore_rules(&mut self, new_rules: I) where diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__add_property.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__add_property.snap index 80730e29..9b3650fa 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__add_property.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__add_property.snap @@ -11,8 +11,7 @@ properties: metadata: ignore_unknown_instances: false relevant_paths: [] - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_after_patch.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_after_patch.snap index d53e0090..afbcc2ee 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_after_patch.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_after_patch.snap @@ -9,8 +9,7 @@ properties: {} metadata: ignore_unknown_instances: false relevant_paths: [] - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_initial.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_initial.snap index abfbd69f..31af7868 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_initial.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__remove_property_initial.snap @@ -11,8 +11,7 @@ properties: metadata: ignore_unknown_instances: false relevant_paths: [] - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__set_name_and_class_name.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__set_name_and_class_name.snap index 5af96846..ecb3f847 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__set_name_and_class_name.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__apply__set_name_and_class_name.snap @@ -9,8 +9,7 @@ properties: {} metadata: ignore_unknown_instances: false relevant_paths: [] - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap b/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap index 2f112685..1bd818f3 100644 --- a/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap +++ b/src/snapshot/tests/snapshots/librojo__snapshot__tests__compute__add_child.snap @@ -10,8 +10,7 @@ added_instances: metadata: ignore_unknown_instances: false relevant_paths: [] - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/csv.rs b/src/snapshot_middleware/csv.rs index 95b9b15a..2a5e0c3b 100644 --- a/src/snapshot_middleware/csv.rs +++ b/src/snapshot_middleware/csv.rs @@ -10,9 +10,8 @@ use rbx_dom_weak::{types::Variant, ustr}; use serde::{Deserialize, Serialize}; use crate::{ - path_encoding::encode_path_name, snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, - syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, + syncback::{name_needs_slugify, slugify_name, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, }; use super::{ @@ -78,10 +77,16 @@ pub fn snapshot_csv_init( let mut init_snapshot = snapshot_csv(context, vfs, init_path, &dir_snapshot.name)?.unwrap(); + // Preserve the init script's instigating_source (the actual file path) + // before copying the directory's metadata (which has the folder path) + let script_instigating_source = init_snapshot.metadata.instigating_source.take(); + init_snapshot.children = dir_snapshot.children; init_snapshot.metadata = dir_snapshot.metadata; - // The directory snapshot middleware includes all possible init paths - // so we don't need to add it here. + + // Restore the init script's instigating_source so two-way sync writes + // to the actual file (e.g., init.csv) instead of the directory + init_snapshot.metadata.instigating_source = script_instigating_source; DirectoryMetadata::read_and_apply_all(vfs, folder_path, &mut init_snapshot)?; @@ -110,10 +115,20 @@ pub fn syncback_csv<'sync>( if !meta.is_empty() { let parent = snapshot.path.parent_err()?; - let meta_name = if snapshot.encode_windows_invalid_chars() { - encode_path_name(&new_inst.name) + let meta_name = snapshot + .path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let meta_name = if meta_name.is_empty() { + let instance_name = &new_inst.name; + if name_needs_slugify(instance_name) { + slugify_name(instance_name) + } else { + instance_name.clone() + } } else { - new_inst.name.clone() + meta_name.to_string() }; fs_snapshot.add_file( parent.join(format!("{}.meta.json5", meta_name)), diff --git a/src/snapshot_middleware/dir.rs b/src/snapshot_middleware/dir.rs index 7966abb2..e26084cd 100644 --- a/src/snapshot_middleware/dir.rs +++ b/src/snapshot_middleware/dir.rs @@ -8,7 +8,11 @@ use memofs::{DirEntry, Vfs}; use crate::{ snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource}, - syncback::{hash_instance, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, + snapshot_middleware::Middleware, + syncback::{ + hash_instance, name_needs_slugify, slugify_name, strip_middleware_extension, FsSnapshot, + SyncbackReturn, SyncbackSnapshot, + }, }; use super::{meta_file::DirectoryMetadata, snapshot_from_vfs}; @@ -138,6 +142,12 @@ pub fn syncback_dir_no_meta<'sync>( let mut children = Vec::new(); let mut removed_children = Vec::new(); + // taken_names tracks claimed bare slugs (without extensions) for dedup. + // Pre-seeded from old tree children's slugified instance names so that + // new-only children correctly dedup against existing siblings, regardless + // of iteration order (see plan: fix_stem-level_dedup §2). + let mut taken_names: HashSet = HashSet::new(); + // Detect duplicate child names (case-insensitive for file system safety). // We skip duplicates instead of failing, tracking them in stats. let mut child_name_counts: HashMap = HashMap::new(); @@ -177,7 +187,49 @@ pub fn syncback_dir_no_meta<'sync>( old_child_map.insert(inst.name(), inst); } - for new_child_ref in new_inst.children() { + // Pre-seed taken_names from old children's actual filesystem dedup keys + // so that new-only children correctly dedup against existing siblings. + // We derive keys from relevant_paths (the real filename on disk) rather + // than slugifying instance names, because an old instance may already + // have a tilde suffix (e.g. A_B~1.luau from prior dedup) that the bare + // slug wouldn't capture. + // Only in incremental mode: in clean mode, old_ref is forced to None + // for all children, so every child is treated as new and pre-seeding + // would cause false collisions (spurious ~1 suffixes). + if snapshot.data.is_incremental() { + for inst in old_child_map.values() { + if let Some(path) = inst.metadata().relevant_paths.first() { + if let Some(filename) = path.file_name().and_then(|f| f.to_str()) { + let middleware = inst.metadata().middleware.unwrap_or(Middleware::Dir); + let dedup_key = + strip_middleware_extension(filename, middleware).to_lowercase(); + taken_names.insert(dedup_key); + } + } else { + // Fallback (shouldn't happen for old instances with disk presence) + let name = inst.name(); + let slug = if name_needs_slugify(name) { + slugify_name(name).to_lowercase() + } else { + name.to_lowercase() + }; + taken_names.insert(slug); + } + } + } + + // Sort children alphabetically for deterministic dedup ordering. + // Without this, DOM iteration order determines which sibling gets + // the base slug vs ~1, causing non-deterministic output across + // different serializations of the same place. + let mut sorted_children: Vec<_> = new_inst + .children() + .iter() + .map(|r| (*r, snapshot.get_new_instance(*r).unwrap().name.clone())) + .collect(); + sorted_children.sort_by(|(_, a), (_, b)| a.cmp(b)); + + for (new_child_ref, _) in &sorted_children { let new_child = snapshot.get_new_instance(*new_child_ref).unwrap(); // Skip children with duplicate names - cannot reliably sync them @@ -220,10 +272,19 @@ pub fn syncback_dir_no_meta<'sync>( continue; } // This child exists in both doms. Pass it on. - children.push(snapshot.with_joined_path(*new_child_ref, Some(old_child.id()))?); + let (child_snap, _needs_meta, dedup_key) = snapshot.with_joined_path( + *new_child_ref, + Some(old_child.id()), + &taken_names, + )?; + taken_names.insert(dedup_key.to_lowercase()); + children.push(child_snap); } else { // The child only exists in the the new dom - children.push(snapshot.with_joined_path(*new_child_ref, None)?); + let (child_snap, _needs_meta, dedup_key) = + snapshot.with_joined_path(*new_child_ref, None, &taken_names)?; + taken_names.insert(dedup_key.to_lowercase()); + children.push(child_snap); } } // Any children that are in the old dom but not the new one are removed. @@ -246,7 +307,15 @@ pub fn syncback_dir_no_meta<'sync>( })); } else { // There is no old instance. Just add every child. - for new_child_ref in new_inst.children() { + // Sort alphabetically for deterministic dedup ordering. + let mut sorted_children: Vec<_> = new_inst + .children() + .iter() + .map(|r| (*r, snapshot.get_new_instance(*r).unwrap().name.clone())) + .collect(); + sorted_children.sort_by(|(_, a), (_, b)| a.cmp(b)); + + for (new_child_ref, _) in &sorted_children { let new_child = snapshot.get_new_instance(*new_child_ref).unwrap(); // Skip children with duplicate names - cannot reliably sync them @@ -267,7 +336,10 @@ pub fn syncback_dir_no_meta<'sync>( if snapshot.should_ignore_tree(*new_child_ref) { continue; } - children.push(snapshot.with_joined_path(*new_child_ref, None)?); + let (child_snap, _needs_meta, dedup_key) = + snapshot.with_joined_path(*new_child_ref, None, &taken_names)?; + taken_names.insert(dedup_key.to_lowercase()); + children.push(child_snap); } } let mut fs_snapshot = FsSnapshot::new(); diff --git a/src/snapshot_middleware/json_model.rs b/src/snapshot_middleware/json_model.rs index cc312258..064d1ff6 100644 --- a/src/snapshot_middleware/json_model.rs +++ b/src/snapshot_middleware/json_model.rs @@ -35,20 +35,14 @@ pub fn snapshot_json_model( format!("File is not a valid JSON model: {}", path.display()) })?; - if let Some(top_level_name) = &instance.name { - let new_name = format!("{}.model.json5", top_level_name); + // If the JSON has a name property, preserve it in metadata for syncback + let specified_name = instance.name.clone(); - log::warn!( - "Model at path {} had a top-level Name field. \ - This field has been ignored since Rojo 6.0.\n\ - Consider removing this field and renaming the file to {}.", - new_name, - path.display() - ); + // Use the name from JSON if present, otherwise fall back to filename-derived name + if instance.name.is_none() { + instance.name = Some(name.to_owned()); } - instance.name = Some(name.to_owned()); - let id = instance.id.take().map(RojoRef::new); let schema = instance.schema.take(); @@ -62,7 +56,8 @@ pub fn snapshot_json_model( .relevant_paths(vec![vfs.canonicalize(path)?]) .context(context) .specified_id(id) - .schema(schema); + .schema(schema) + .specified_name(specified_name); Ok(Some(snapshot)) } @@ -81,6 +76,14 @@ pub fn syncback_json_model<'sync>( // schemas will ever exist in one project for it to matter, but it // could have a performance cost. model.schema = old_inst.metadata().schema.clone(); + model.name = old_inst.metadata().specified_name.clone(); + } + // Filesystem name differs from the instance name (due to slugification + // or deduplication). Store the real name so it survives a round-trip. + // Checked independently: old_inst may exist but lack a specified_name + // (e.g., a previously filesystem-safe name was renamed to need slugification). + if model.name.is_none() && snapshot.needs_meta_name { + model.name = Some(snapshot.new_inst().name.clone()); } let serialized = match crate::json::to_vec_pretty_sorted(&model) { diff --git a/src/snapshot_middleware/lua.rs b/src/snapshot_middleware/lua.rs index 3fc31824..0d79d8af 100644 --- a/src/snapshot_middleware/lua.rs +++ b/src/snapshot_middleware/lua.rs @@ -8,9 +8,8 @@ use rbx_dom_weak::{ }; use crate::{ - path_encoding::encode_path_name, snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, - syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, + syncback::{name_needs_slugify, slugify_name, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, }; use super::{ @@ -165,10 +164,11 @@ pub fn syncback_lua<'sync>( .or_else(|| file_stem.strip_suffix(".legacy")) .unwrap_or(file_stem); let meta_name = if meta_name.is_empty() { - if snapshot.encode_windows_invalid_chars() { - encode_path_name(&new_inst.name) + let instance_name = &new_inst.name; + if name_needs_slugify(instance_name) { + slugify_name(instance_name) } else { - new_inst.name.clone() + instance_name.clone() } } else { meta_name.to_string() diff --git a/src/snapshot_middleware/meta_file.rs b/src/snapshot_middleware/meta_file.rs index 2f929127..e9f17708 100644 --- a/src/snapshot_middleware/meta_file.rs +++ b/src/snapshot_middleware/meta_file.rs @@ -36,6 +36,9 @@ pub struct AdjacentMetadata { #[serde(default, skip_serializing_if = "IndexMap::is_empty")] pub attributes: IndexMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip)] pub path: PathBuf, } @@ -52,9 +55,8 @@ impl AdjacentMetadata { _name: &str, snapshot: &mut InstanceSnapshot, ) -> anyhow::Result<()> { - // Use the file stem from the actual path (which has encoded chars) - // rather than the decoded name, so meta files are found correctly - // when encodeWindowsInvalidChars is enabled. + // Use the file stem from the actual path (which may be slugified) + // rather than the instance name, so meta files are found correctly. // Strip known script type suffixes to get the base name for the meta file. let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let base_name = file_stem @@ -156,6 +158,20 @@ impl AdjacentMetadata { } } + let name = snapshot + .old_inst() + .and_then(|inst| inst.metadata().specified_name.clone()) + .or_else(|| { + // Filesystem name differs from the instance name (due to + // slugification or deduplication). Store the real name so + // it survives a round-trip through the filesystem. + if snapshot.needs_meta_name { + Some(snapshot.new_inst().name.clone()) + } else { + None + } + }); + Ok(Some(Self { ignore_unknown_instances: if ignore_unknown_instances { Some(true) @@ -164,6 +180,7 @@ impl AdjacentMetadata { }, properties, attributes, + name, path, id: None, schema, @@ -225,11 +242,26 @@ impl AdjacentMetadata { Ok(()) } + fn apply_name(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> { + if self.name.is_some() && snapshot.metadata.specified_name.is_some() { + anyhow::bail!( + "cannot specify a name using {} (instance has a name from somewhere else)", + self.path.display() + ); + } + if let Some(name) = &self.name { + snapshot.name = name.clone().into(); + } + snapshot.metadata.specified_name = self.name.take(); + Ok(()) + } + pub fn apply_all(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> { self.apply_ignore_unknown_instances(snapshot); self.apply_properties(snapshot)?; self.apply_id(snapshot)?; self.apply_schema(snapshot)?; + self.apply_name(snapshot)?; Ok(()) } @@ -238,11 +270,13 @@ impl AdjacentMetadata { /// /// - The number of properties and attributes is 0 /// - `ignore_unknown_instances` is None + /// - `name` is None #[inline] pub fn is_empty(&self) -> bool { self.attributes.is_empty() && self.properties.is_empty() && self.ignore_unknown_instances.is_none() + && self.name.is_none() } // TODO: Add method to allow selectively applying parts of metadata and @@ -274,6 +308,9 @@ pub struct DirectoryMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub class_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip)] pub path: PathBuf, } @@ -383,6 +420,20 @@ impl DirectoryMetadata { } } + let name = snapshot + .old_inst() + .and_then(|inst| inst.metadata().specified_name.clone()) + .or_else(|| { + // Filesystem name differs from the instance name (due to + // slugification or deduplication). Store the real name so + // it survives a round-trip through the filesystem. + if snapshot.needs_meta_name { + Some(snapshot.new_inst().name.clone()) + } else { + None + } + }); + Ok(Some(Self { ignore_unknown_instances: if ignore_unknown_instances { Some(true) @@ -392,6 +443,7 @@ impl DirectoryMetadata { properties, attributes, class_name: None, + name, path, id: None, schema, @@ -404,6 +456,7 @@ impl DirectoryMetadata { self.apply_properties(snapshot)?; self.apply_id(snapshot)?; self.apply_schema(snapshot)?; + self.apply_name(snapshot)?; Ok(()) } @@ -475,17 +528,34 @@ impl DirectoryMetadata { snapshot.metadata.schema = self.schema.take(); Ok(()) } + + fn apply_name(&mut self, snapshot: &mut InstanceSnapshot) -> anyhow::Result<()> { + if self.name.is_some() && snapshot.metadata.specified_name.is_some() { + anyhow::bail!( + "cannot specify a name using {} (instance has a name from somewhere else)", + self.path.display() + ); + } + if let Some(name) = &self.name { + snapshot.name = name.clone().into(); + } + snapshot.metadata.specified_name = self.name.take(); + Ok(()) + } + /// Returns whether the metadata is 'empty', meaning it doesn't have anything /// worth persisting in it. Specifically: /// /// - The number of properties and attributes is 0 /// - `ignore_unknown_instances` is None /// - `class_name` is either None or not Some("Folder") + /// - `name` is None #[inline] pub fn is_empty(&self) -> bool { self.attributes.is_empty() && self.properties.is_empty() && self.ignore_unknown_instances.is_none() + && self.name.is_none() && if let Some(class) = &self.class_name { class == "Folder" } else { diff --git a/src/snapshot_middleware/mod.rs b/src/snapshot_middleware/mod.rs index 5d60c931..7ea64881 100644 --- a/src/snapshot_middleware/mod.rs +++ b/src/snapshot_middleware/mod.rs @@ -30,7 +30,6 @@ use serde::{Deserialize, Serialize}; use crate::{ glob::Glob, - path_encoding::decode_path_name, project::DEFAULT_PROJECT_NAMES, syncback::{SyncbackReturn, SyncbackSnapshot}, }; @@ -72,19 +71,17 @@ pub fn snapshot_from_vfs( if meta.is_dir() { let (middleware, dir_name, init_path) = get_dir_middleware(vfs, path)?; - // Decode directory name if the flag is set - let decoded_name = if context.decode_windows_invalid_chars { - decode_path_name(dir_name) - } else { - dir_name.to_string() - }; + // The directory name is used as-is from the filesystem. + // If a different instance name is desired, it comes from the + // `name` field in init.meta.json / init.meta.json5 (applied later + // by DirectoryMetadata::apply_name). // TODO: Support user defined init paths // If and when we do, make sure to go support it in // `Project::set_file_name`, as right now it special-cases // `default.project.json5` as an `init` path. match middleware { - Middleware::Dir => middleware.snapshot(context, vfs, path, &decoded_name), - _ => middleware.snapshot(context, vfs, &init_path, &decoded_name), + Middleware::Dir => middleware.snapshot(context, vfs, path, dir_name), + _ => middleware.snapshot(context, vfs, &init_path, dir_name), } } else { let file_name = path @@ -166,23 +163,17 @@ fn snapshot_from_path( vfs: &Vfs, path: &Path, ) -> anyhow::Result> { - // Helper to decode name if the flag is set - let decode_name = |name: &str| -> String { - if context.decode_windows_invalid_chars { - decode_path_name(name) - } else { - name.to_string() - } - }; - + // File names are used as-is from the filesystem. If a different instance + // name is needed (e.g. for names with special chars), it comes from the + // `name` field in adjacent .meta.json / .model.json files. if let Some(rule) = context.get_user_sync_rule(path) { - let name = decode_name(rule.file_name_for_path(path)?); - return rule.middleware.snapshot(context, vfs, path, &name); + let name = rule.file_name_for_path(path)?; + return rule.middleware.snapshot(context, vfs, path, name); } else { for rule in default_sync_rules() { if rule.matches(path) { - let name = decode_name(rule.file_name_for_path(path)?); - return rule.middleware.snapshot(context, vfs, path, &name); + let name = rule.file_name_for_path(path)?; + return rule.middleware.snapshot(context, vfs, path, name); } } } diff --git a/src/snapshot_middleware/project.rs b/src/snapshot_middleware/project.rs index bccce551..e73f2719 100644 --- a/src/snapshot_middleware/project.rs +++ b/src/snapshot_middleware/project.rs @@ -20,7 +20,10 @@ use crate::{ PathIgnoreRule, SyncRule, }, snapshot_middleware::Middleware, - syncback::{filter_properties, inst_path, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, + syncback::{ + filter_properties, inst_path, name_needs_slugify, slugify_name, strip_middleware_extension, + FsSnapshot, SyncbackReturn, SyncbackSnapshot, + }, variant_eq::variant_eq, RojoRef, }; @@ -647,7 +650,55 @@ pub fn syncback_project<'sync>( // All of the children in this loop are by their nature not in the // project, so we just need to run syncback on them. - for (name, new_child) in new_child_map.drain() { + // + // Track taken names (bare slugs) per parent directory so siblings + // under the same parent deduplicate against each other. + let mut taken_names_per_dir: std::collections::HashMap< + std::path::PathBuf, + std::collections::HashSet, + > = std::collections::HashMap::new(); + + // Pre-seed taken_names from old children's actual filesystem dedup keys + // so that new-only children correctly dedup against existing siblings. + // We derive keys from relevant_paths (the real filename on disk) rather + // than slugifying instance names, because an old instance may already + // have a tilde suffix (e.g. A_B~1.luau from prior dedup) that the bare + // slug wouldn't capture. + // Only in incremental mode: in clean mode, old_ref is forced to None + // for all children, so every child is treated as new and pre-seeding + // would cause false collisions (spurious ~1 suffixes). + if snapshot.data.is_incremental() { + if let Some(parent_path) = ref_to_path_map.get(&new_inst.referent()) { + let taken = taken_names_per_dir.entry(parent_path.clone()).or_default(); + for old_child in old_child_map.values() { + if let Some(path) = old_child.metadata().relevant_paths.first() { + if let Some(filename) = path.file_name().and_then(|f| f.to_str()) { + let middleware = + old_child.metadata().middleware.unwrap_or(Middleware::Dir); + let dedup_key = + strip_middleware_extension(filename, middleware).to_lowercase(); + taken.insert(dedup_key); + } + } else { + // Fallback (shouldn't happen for old instances with disk presence) + let name = old_child.name(); + let slug = if name_needs_slugify(name) { + slugify_name(name).to_lowercase() + } else { + name.to_lowercase() + }; + taken.insert(slug); + } + } + } + } + + // Sort remaining children by name for deterministic dedup ordering. + // Without this, HashMap iteration order determines which sibling gets + // the base slug vs ~1, causing non-deterministic output across runs. + let mut remaining_children: Vec<_> = new_child_map.drain().collect(); + remaining_children.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); + for (name, new_child) in remaining_children { // Skip instances of ignored classes if snapshot.should_ignore_class(&new_child.class) { // Also remove from old_child_map so it won't be marked as removed @@ -680,11 +731,15 @@ pub fn syncback_project<'sync>( // concern with directories because they're singular things, // files that contain their own children. if parent_middleware != Middleware::Project { - descendant_snapshots.push(snapshot.with_base_path( + let taken_names = taken_names_per_dir.entry(parent_path.clone()).or_default(); + let (child_snap, _needs_meta, dedup_key) = snapshot.with_base_path( &parent_path, new_child.referent(), None, - )?); + taken_names, + )?; + taken_names.insert(dedup_key.to_lowercase()); + descendant_snapshots.push(child_snap); } } } diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap index 49ba1e21..2ec17a3a 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.csv - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap index 4a178832..a28c445a 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init.snap @@ -6,7 +6,7 @@ snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: - Path: /root + Path: /root/init.csv relevant_paths: - /root - /root/init.luau @@ -19,8 +19,7 @@ metadata: - /root/init.server.lua - /root/init.client.lua - /root/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap index 85e790a2..ada8a161 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_init_with_meta.snap @@ -6,7 +6,7 @@ snapshot_id: "00000000000000000000000000000000" metadata: ignore_unknown_instances: false instigating_source: - Path: /root + Path: /root/init.csv relevant_paths: - /root - /root/init.luau @@ -19,8 +19,7 @@ metadata: - /root/init.server.lua - /root/init.client.lua - /root/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap index ae282829..196e430d 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__csv__test__csv_with_meta.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.csv - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap index f1c96a44..23ecb41c 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__empty_folder.snap @@ -19,8 +19,7 @@ metadata: - /foo/init.server.lua - /foo/init.client.lua - /foo/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap index 52ca0c33..0d7dddbf 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__dir__test__folder_in_folder.snap @@ -19,8 +19,7 @@ metadata: - /foo/init.server.lua - /foo/init.client.lua - /foo/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ @@ -45,8 +44,7 @@ children: - /foo/Child/init.server.lua - /foo/Child/init.client.lua - /foo/Child/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: dir schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap index 00114ed0..c2ca07dc 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__instance_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.json5 - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__with_metadata.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__with_metadata.snap index 99cc6fe4..396cc181 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__with_metadata.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json__test__with_metadata.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.json5 - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap index 13bb28ec..9690c7ec 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs.snap @@ -9,8 +9,7 @@ metadata: Path: /foo.model.json5 relevant_paths: - /foo.model.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ @@ -24,8 +23,7 @@ children: metadata: ignore_unknown_instances: false relevant_paths: [] - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap index 13bb28ec..9690c7ec 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__json_model__test__model_from_vfs_legacy.snap @@ -9,8 +9,7 @@ metadata: Path: /foo.model.json5 relevant_paths: - /foo.model.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ @@ -24,8 +23,7 @@ children: metadata: ignore_unknown_instances: false relevant_paths: [] - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__client_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__client_from_vfs.snap index 60e211ad..ea4d0102 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__client_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__client_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.client.luau - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs.snap index 3178e91f..e4978c2b 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs.snap @@ -19,8 +19,7 @@ metadata: - /root/init.server.lua - /root/init.client.lua - /root/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs_with_meta.snap index 38364335..da906c76 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__init_module_from_vfs_with_meta.snap @@ -19,8 +19,7 @@ metadata: - /root/init.server.lua - /root/init.client.lua - /root/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__legacy_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__legacy_from_vfs.snap index 0c98cf63..b609a3a3 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__legacy_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__legacy_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.legacy.luau - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__local_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__local_from_vfs.snap index ab97b74c..a8c84cba 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__local_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__local_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.local.luau - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_from_vfs.snap index a9312d8a..30a628f0 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.luau - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_with_meta.snap index 3f40a281..50818283 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__module_with_meta.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.luau - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__plugin_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__plugin_from_vfs.snap index 5f3414cc..daf0d5b5 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__plugin_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__plugin_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.plugin.luau - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_disabled.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_disabled.snap index 585120e6..ffa29e3c 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_disabled.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_disabled.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /bar.server.luau - /bar.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_from_vfs.snap index 6c272e47..756c6567 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.server.luau - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_with_meta.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_with_meta.snap index 90a0788b..78e84a4c 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_with_meta.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__lua__test__server_with_meta.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.server.luau - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_json.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_json.snap index 854eafeb..f5329161 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_json.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_json.snap @@ -9,8 +9,7 @@ metadata: - /foo/bar.meta.json - /foo/bar.meta.jsonc - /foo/bar.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_json5.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_json5.snap index 107603bd..f15c6ff5 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_json5.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_json5.snap @@ -7,8 +7,7 @@ metadata: ignore_unknown_instances: false relevant_paths: - /foo/bar.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_jsonc.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_jsonc.snap index 854eafeb..f5329161 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_jsonc.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__adjacent_read_jsonc.snap @@ -9,8 +9,7 @@ metadata: - /foo/bar.meta.json - /foo/bar.meta.jsonc - /foo/bar.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_json.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_json.snap index 09c35784..49729bff 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_json.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_json.snap @@ -9,8 +9,7 @@ metadata: - /foo/init.meta.json - /foo/init.meta.jsonc - /foo/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_json5.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_json5.snap index 1f5d141b..5f7ab6cd 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_json5.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_json5.snap @@ -7,8 +7,7 @@ metadata: ignore_unknown_instances: false relevant_paths: - /foo/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_jsonc.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_jsonc.snap index 09c35784..49729bff 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_jsonc.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__meta_file__test__directory_read_jsonc.snap @@ -9,8 +9,7 @@ metadata: - /foo/init.meta.json - /foo/init.meta.jsonc - /foo/init.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__no_name_project.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__no_name_project.snap index 151f7990..0e0a7fc5 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__no_name_project.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__no_name_project.snap @@ -9,8 +9,7 @@ metadata: Path: /foo/default.project.json5 relevant_paths: - /foo/default.project.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap index ce488a4c..07a7aca4 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_from_direct_file.snap @@ -9,8 +9,7 @@ metadata: Path: /foo/hello.project.json5 relevant_paths: - /foo/hello.project.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap index 124e588a..b19c2181 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_path_property_overrides.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo/other.project.json5 - /foo/default.project.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: project schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap index 6250d397..2df85f3f 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_children.snap @@ -9,8 +9,7 @@ metadata: Path: /foo.project.json5 relevant_paths: - /foo.project.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ @@ -29,8 +28,7 @@ children: $className: Model parent_class: Folder relevant_paths: [] - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap index e2be0084..901bf4a4 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo/other.project.json5 - /foo/default.project.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: project schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap index 5f24e8ef..c93e6832 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_project_with_children.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo/other.project.json5 - /foo/default.project.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: project schema: ~ @@ -30,8 +29,7 @@ children: $className: Model parent_class: Folder relevant_paths: [] - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap index eb10ca84..8dc96da2 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_path_to_txt.snap @@ -11,8 +11,7 @@ metadata: - /foo/other.txt - /foo/other.meta.json5 - /foo/default.project.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: text schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap index c9615222..88599384 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_resolved_properties.snap @@ -9,8 +9,7 @@ metadata: Path: /foo.project.json5 relevant_paths: - /foo.project.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap index d3506cd6..a953d6be 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__project__test__project_with_unresolved_properties.snap @@ -9,8 +9,7 @@ metadata: Path: /foo.project.json5 relevant_paths: - /foo.project.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap index 979aba72..cbee1125 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__instance_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.toml - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__with_metadata.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__with_metadata.snap index 6bd178df..46f5eb5e 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__with_metadata.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__toml__test__with_metadata.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - foo.toml - foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap index 40bb1f5f..f89b5009 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__instance_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.txt - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__with_metadata.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__with_metadata.snap index a732b1f5..aefd549f 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__with_metadata.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__txt__test__with_metadata.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.txt - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__yaml__test__instance_from_vfs.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__yaml__test__instance_from_vfs.snap index 3ae974c1..f17cdf34 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__yaml__test__instance_from_vfs.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__yaml__test__instance_from_vfs.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - /foo.yaml - /foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: ~ middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__yaml__test__with_metadata.snap b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__yaml__test__with_metadata.snap index 4d87863b..f54d3e7d 100644 --- a/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__yaml__test__with_metadata.snap +++ b/src/snapshot_middleware/snapshots/librojo__snapshot_middleware__yaml__test__with_metadata.snap @@ -10,8 +10,7 @@ metadata: relevant_paths: - foo.yaml - foo.meta.json5 - context: - decode_windows_invalid_chars: true + context: {} specified_id: manually specified middleware: ~ schema: ~ diff --git a/src/snapshot_middleware/txt.rs b/src/snapshot_middleware/txt.rs index 48216a0b..17e65145 100644 --- a/src/snapshot_middleware/txt.rs +++ b/src/snapshot_middleware/txt.rs @@ -6,9 +6,8 @@ use rbx_dom_weak::types::Variant; use rbx_dom_weak::ustr; use crate::{ - path_encoding::encode_path_name, snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, - syncback::{FsSnapshot, SyncbackReturn, SyncbackSnapshot}, + syncback::{name_needs_slugify, slugify_name, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, }; use super::{meta_file::AdjacentMetadata, PathExt as _}; @@ -59,10 +58,20 @@ pub fn syncback_txt<'sync>( if !meta.is_empty() { let parent = snapshot.path.parent_err()?; - let meta_name = if snapshot.encode_windows_invalid_chars() { - encode_path_name(&new_inst.name) + let meta_name = snapshot + .path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let meta_name = if meta_name.is_empty() { + let instance_name = &new_inst.name; + if name_needs_slugify(instance_name) { + slugify_name(instance_name) + } else { + instance_name.clone() + } } else { - new_inst.name.clone() + meta_name.to_string() }; fs_snapshot.add_file( parent.join(format!("{}.meta.json5", meta_name)), diff --git a/src/syncback/file_names.rs b/src/syncback/file_names.rs index 904fb9a6..9f8e1b43 100644 --- a/src/syncback/file_names.rs +++ b/src/syncback/file_names.rs @@ -2,38 +2,60 @@ //! middleware. use std::borrow::Cow; +use std::collections::HashSet; use anyhow::Context; use rbx_dom_weak::Instance; -use crate::{ - path_encoding::encode_path_name, snapshot::InstanceWithMeta, snapshot_middleware::Middleware, -}; +use crate::{snapshot::InstanceWithMeta, snapshot_middleware::Middleware}; -pub fn name_for_inst<'old>( +/// Generates a filesystem name for an instance. +/// Returns `(filename, needs_meta_name, dedup_key)`. +/// +/// - `filename`: The full filesystem name (including extension for file middleware). +/// - `needs_meta_name`: `true` when the filesystem name differs from the instance +/// name (meaning a `name` field must be written in metadata). +/// - `dedup_key`: The bare slug (without extension) that callers must insert into +/// `taken_names`. This is the name-level identifier used for collision detection. +/// Callers should **always** use `dedup_key` (not `filename`) when accumulating +/// into `taken_names`. +/// +/// If `old_inst` exists, its existing path is preserved (incremental mode). +/// For new instances, names with forbidden chars are slugified and deduplicated +/// against `taken_names`. +pub fn name_for_inst<'a>( middleware: Middleware, - new_inst: &Instance, - old_inst: Option>, - encode_invalid_chars: bool, -) -> anyhow::Result> { + new_inst: &'a Instance, + old_inst: Option>, + taken_names: &HashSet, +) -> anyhow::Result<(Cow<'a, str>, bool, String)> { if let Some(old_inst) = old_inst { if let Some(source) = old_inst.metadata().relevant_paths.first() { - source + let name = source .file_name() .and_then(|s| s.to_str()) - .map(Cow::Borrowed) - .context("sources on the file system should be valid unicode and not be stubs") + .context("sources on the file system should be valid unicode and not be stubs")?; + // Derive dedup_key by stripping the middleware extension from the + // filename. For Dir middleware the extension is empty so this is a + // no-op. This keeps extension logic inside this function. + let dedup_key = strip_middleware_extension(name, middleware); + Ok((Cow::Borrowed(name), false, dedup_key)) } else { - // This is technically not /always/ true, but we want to avoid - // running syncback on anything that has no instigating source - // anyway. anyhow::bail!( "members of 'old' trees should have an instigating source. Somehow, {} did not.", old_inst.name(), ); } } else { - Ok(match middleware { + // Determine base name: slugify if the raw name isn't filesystem-safe + let needs_slugify = name_needs_slugify(&new_inst.name); + let base = if needs_slugify { + slugify_name(&new_inst.name) + } else { + new_inst.name.clone() + }; + + match middleware { Middleware::Dir | Middleware::CsvDir | Middleware::ServerScriptDir @@ -42,45 +64,48 @@ pub fn name_for_inst<'old>( | Middleware::PluginScriptDir | Middleware::LocalScriptDir | Middleware::LegacyScriptDir => { - let name = if encode_invalid_chars { - let encoded = encode_path_name(&new_inst.name); - // Validate for issues not fixed by encoding (trailing space/dot, control chars, reserved names) - validate_file_name(&encoded).with_context(|| { - format!("name '{}' (encoded from '{}') is not legal to write to the file system", encoded, new_inst.name) - })?; - encoded - } else { - validate_file_name(&new_inst.name).with_context(|| { - format!( - "name '{}' is not legal to write to the file system", - new_inst.name - ) - })?; - new_inst.name.clone() - }; - Cow::Owned(name) + let deduped = deduplicate_name(&base, taken_names); + let needs_meta = needs_slugify || deduped != base; + let dedup_key = deduped.clone(); + Ok((Cow::Owned(deduped), needs_meta, dedup_key)) } _ => { let extension = extension_for_middleware(middleware); - let name = if encode_invalid_chars { - let encoded = encode_path_name(&new_inst.name); - // Validate for issues not fixed by encoding (trailing space/dot, control chars, reserved names) - validate_file_name(&encoded).with_context(|| { - format!("name '{}' (encoded from '{}') is not legal to write to the file system", encoded, new_inst.name) - })?; - encoded - } else { - validate_file_name(&new_inst.name).with_context(|| { - format!( - "name '{}' is not legal to write to the file system", - new_inst.name - ) - })?; - new_inst.name.clone() - }; - Cow::Owned(format!("{name}.{extension}")) + let deduped = deduplicate_name(&base, taken_names); + let needs_meta = needs_slugify || deduped != base; + let dedup_key = deduped.clone(); + Ok(( + Cow::Owned(format!("{deduped}.{extension}")), + needs_meta, + dedup_key, + )) } - }) + } + } +} + +/// Strips the middleware extension from a filename to recover the bare slug. +/// Used by `name_for_inst` to derive the dedup key for old instances, and by +/// callers that need to seed `taken_names` from filesystem paths. +pub fn strip_middleware_extension(filename: &str, middleware: Middleware) -> String { + // Dir middleware has no extension — filename IS the slug + match middleware { + Middleware::Dir + | Middleware::CsvDir + | Middleware::ServerScriptDir + | Middleware::ClientScriptDir + | Middleware::ModuleScriptDir + | Middleware::PluginScriptDir + | Middleware::LocalScriptDir + | Middleware::LegacyScriptDir => filename.to_string(), + _ => { + let ext = extension_for_middleware(middleware); + let suffix = format!(".{ext}"); + filename + .strip_suffix(&suffix) + .unwrap_or(filename) + .to_string() + } } } @@ -129,6 +154,140 @@ const INVALID_WINDOWS_NAMES: [&str; 22] = [ /// in a file's name. const FORBIDDEN_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '|', '?', '*', '\\']; +/// Characters that must be slugified when converting instance names to +/// filesystem names. Extends FORBIDDEN_CHARS with: +/// - `~` (conflicts with dedup suffix `~1`, `~2`, etc.) +/// +/// Note: spaces are NOT in this list. Middle spaces are valid in filenames. +/// Leading/trailing spaces are handled separately by boundary checks. +const SLUGIFY_CHARS: [char; 10] = ['<', '>', ':', '"', '/', '|', '?', '*', '\\', '~']; + +/// Suffixes (case-insensitive) that, if an instance name ends with one, +/// would create a compound extension that tricks Rojo's sync rule matching. +/// +/// Example: instance `foo.server` + extension `.luau` → `foo.server.luau` +/// which matches `*.server.luau` (ServerScript) instead of the intended +/// `*.luau` (ModuleScript). +const DANGEROUS_SUFFIXES: [&str; 8] = [ + ".server", ".client", ".plugin", ".local", ".legacy", ".meta", ".model", ".project", +]; + +/// Returns `true` if an instance name contains characters or patterns that +/// require slugification for safe filesystem use. +pub fn name_needs_slugify(name: &str) -> bool { + if name.is_empty() { + return true; + } + if name.starts_with(' ') || name.ends_with(' ') || name.ends_with('.') { + return true; + } + for ch in name.chars() { + if SLUGIFY_CHARS.contains(&ch) || ch.is_control() { + return true; + } + } + // Check for dangerous suffixes that would collide with Rojo's compound + // extensions (e.g., ".server" + ".luau" → ".server.luau") + if has_dangerous_suffix(name) { + return true; + } + let name_lower = name.to_lowercase(); + for forbidden in INVALID_WINDOWS_NAMES { + if name_lower == forbidden.to_lowercase() { + return true; + } + } + false +} + +/// Returns `true` if the name ends with a dangerous suffix (case-insensitive). +fn has_dangerous_suffix(name: &str) -> bool { + let lower = name.to_lowercase(); + DANGEROUS_SUFFIXES.iter().any(|s| lower.ends_with(s)) +} + +/// Slugifies a name by replacing forbidden filesystem characters with +/// underscores and ensuring the result is a valid file name. +/// +/// Replaces OS-forbidden chars and `~` (dedup separator) with underscores. +/// Strips leading/trailing spaces. Neutralises dangerous suffixes +/// (`.server`, `.meta`, etc.) that would collide with Rojo's compound +/// extensions by replacing the offending dot with `_`. +/// +/// This is a pure, stateless function. It does NOT handle collisions -- +/// use `deduplicate_name()` after slugifying to resolve those. +pub fn slugify_name(name: &str) -> String { + let mut result = String::with_capacity(name.len()); + + for ch in name.chars() { + if SLUGIFY_CHARS.contains(&ch) || ch.is_control() { + result.push('_'); + } else { + result.push(ch); + } + } + + // Strip leading spaces + while result.starts_with(' ') { + result.remove(0); + } + + // Neutralise dangerous suffixes by replacing the last dot before + // the suffix with an underscore. Loop because replacement could + // reveal another dangerous suffix (e.g., "a.meta.server" → fix + // ".server" → "a.meta_server" → still has ".meta" → fix that too). + while has_dangerous_suffix(&result) { + if let Some(pos) = result.rfind('.') { + result.replace_range(pos..pos + 1, "_"); + } else { + break; + } + } + + // Strip trailing spaces and dots (invalid on Windows) BEFORE the + // reserved-name check so that inputs like "CON." or "CON " are + // reduced to "CON" and then correctly caught below. + while result.ends_with(' ') || result.ends_with('.') { + result.pop(); + } + + // Handle Windows reserved names by appending an underscore + let result_lower = result.to_lowercase(); + for forbidden in INVALID_WINDOWS_NAMES { + if result_lower == forbidden.to_lowercase() { + result.push('_'); + break; + } + } + + // If the result is empty or all underscores, use a fallback + if result.is_empty() || result.chars().all(|c| c == '_') { + result = "instance".to_string(); + } + + result +} + +/// Appends ~1, ~2, etc. to avoid collisions. Returns the name as-is if +/// unclaimed. +/// +/// Comparisons are **case-insensitive** because Windows and macOS have +/// case-insensitive filesystems. `taken_names` must contain **lowercased** +/// entries for this to work correctly. +pub fn deduplicate_name(base: &str, taken_names: &std::collections::HashSet) -> String { + let base_lower = base.to_lowercase(); + if !taken_names.contains(&base_lower) { + return base.to_string(); + } + for i in 1.. { + let candidate = format!("{base}~{i}"); + if !taken_names.contains(&candidate.to_lowercase()) { + return candidate; + } + } + unreachable!() +} + /// Validates a provided file name to ensure it's allowed on the file system. An /// error is returned if the name isn't allowed, indicating why. /// This takes into account rules for Windows, MacOS, and Linux. @@ -161,3 +320,2190 @@ pub fn validate_file_name>(name: S) -> anyhow::Result<()> { Ok(()) } + +/// 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", ".model", +]; + +/// Strips a known script suffix from a file stem. +/// +/// For example, `"MyScript.server"` → `"MyScript"`, but `"MyScript"` → `"MyScript"`. +pub fn strip_script_suffix(stem: &str) -> &str { + for suffix in KNOWN_SCRIPT_SUFFIXES { + if let Some(base) = stem.strip_suffix(suffix) { + return base; + } + } + stem +} + +/// Given a script file path like `parent/Foo_Bar.server.luau`, +/// returns the adjacent meta path `parent/Foo_Bar.meta.json5`. +/// +/// Strips the file extension and any known script suffix (`.server`, `.client`, +/// etc.) to derive the base name, then appends `.meta.json5`. +pub fn adjacent_meta_path(script_path: &std::path::Path) -> std::path::PathBuf { + let stem = script_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let base = strip_script_suffix(stem); + script_path.with_file_name(format!("{}.meta.json5", base)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rbx_dom_weak::{InstanceBuilder, WeakDom}; + + // ── slugify_name ────────────────────────────────────────────────── + + #[test] + fn slugify_clean_name_unchanged() { + assert_eq!(slugify_name("Hello"), "Hello"); + assert_eq!(slugify_name("MyModule"), "MyModule"); + assert_eq!(slugify_name("foo_bar_baz"), "foo_bar_baz"); + } + + #[test] + fn slugify_each_slugify_char() { + for ch in SLUGIFY_CHARS { + let input = format!("A{ch}B"); + let result = slugify_name(&input); + assert_eq!(result, "A_B", "slugify char {ch:?} should become _"); + } + } + + #[test] + fn slugify_dots_preserved_when_safe() { + // Dots are allowed when they don't form a dangerous suffix + assert_eq!(slugify_name("v1.0"), "v1.0"); + assert_eq!(slugify_name("foo.bar"), "foo.bar"); + assert_eq!(slugify_name("hello.world"), "hello.world"); + } + + #[test] + fn slugify_multiple_forbidden_chars() { + assert_eq!(slugify_name("Hey/Bro"), "Hey_Bro"); + assert_eq!(slugify_name("Hey:Bro"), "Hey_Bro"); + assert_eq!(slugify_name("ac:d"), "a_b_c_d"); + assert_eq!(slugify_name("What?Module"), "What_Module"); + } + + #[test] + fn slugify_all_slugify_chars_string() { + // Every char is slugified → all underscores → fallback to "instance" + let input: String = SLUGIFY_CHARS.iter().collect(); + assert_eq!(slugify_name(&input), "instance"); + } + + #[test] + fn slugify_windows_reserved_names() { + assert_eq!(slugify_name("CON"), "CON_"); + assert_eq!(slugify_name("PRN"), "PRN_"); + assert_eq!(slugify_name("AUX"), "AUX_"); + assert_eq!(slugify_name("NUL"), "NUL_"); + assert_eq!(slugify_name("COM1"), "COM1_"); + assert_eq!(slugify_name("LPT9"), "LPT9_"); + } + + #[test] + fn slugify_windows_reserved_case_insensitive() { + assert_eq!(slugify_name("con"), "con_"); + assert_eq!(slugify_name("Con"), "Con_"); + assert_eq!(slugify_name("nul"), "nul_"); + } + + #[test] + fn slugify_windows_reserved_with_trailing_dots_and_spaces() { + // Trailing dots/spaces must be stripped BEFORE the reserved name + // check, otherwise "CON." bypasses the check and becomes "CON". + assert_eq!(slugify_name("CON."), "CON_"); + assert_eq!(slugify_name("CON "), "CON_"); + assert_eq!(slugify_name("CON.."), "CON_"); + assert_eq!(slugify_name("CON. "), "CON_"); + assert_eq!(slugify_name("PRN."), "PRN_"); + assert_eq!(slugify_name("AUX "), "AUX_"); + assert_eq!(slugify_name("nul."), "nul_"); + assert_eq!(slugify_name("com1."), "com1_"); + assert_eq!(slugify_name("LPT1 "), "LPT1_"); + } + + #[test] + fn slugify_trailing_dot() { + // Trailing dots are stripped (invalid on Windows) + assert_eq!(slugify_name("hello."), "hello"); + assert_eq!(slugify_name("hello.."), "hello"); + } + + #[test] + fn slugify_space() { + // Middle spaces are preserved (valid in filenames) + assert_eq!(slugify_name("hello world"), "hello world"); + // Trailing spaces are stripped + assert_eq!(slugify_name("hello "), "hello"); + // Leading spaces are stripped + assert_eq!(slugify_name(" hello"), "hello"); + // All spaces → empty after strip → fallback + assert_eq!(slugify_name(" "), "instance"); + assert_eq!(slugify_name(" "), "instance"); + } + + #[test] + fn slugify_trailing_dot_and_space_mixed() { + // "hello. " → trailing " " stripped → "hello." → trailing "." stripped → "hello" + assert_eq!(slugify_name("hello. "), "hello"); + // "hello ." → trailing "." stripped → "hello " → trailing " " stripped → "hello" + assert_eq!(slugify_name("hello ."), "hello"); + } + + #[test] + fn slugify_dangerous_suffixes() { + // Suffixes that would collide with Rojo's compound extensions + assert_eq!(slugify_name("foo.server"), "foo_server"); + assert_eq!(slugify_name("foo.client"), "foo_client"); + assert_eq!(slugify_name("foo.plugin"), "foo_plugin"); + assert_eq!(slugify_name("foo.local"), "foo_local"); + assert_eq!(slugify_name("foo.legacy"), "foo_legacy"); + assert_eq!(slugify_name("foo.meta"), "foo_meta"); + assert_eq!(slugify_name("foo.model"), "foo_model"); + assert_eq!(slugify_name("foo.project"), "foo_project"); + } + + #[test] + fn slugify_dangerous_suffixes_case_insensitive() { + assert_eq!(slugify_name("foo.Server"), "foo_Server"); + assert_eq!(slugify_name("foo.META"), "foo_META"); + assert_eq!(slugify_name("foo.Model"), "foo_Model"); + } + + #[test] + fn slugify_nested_dangerous_suffixes() { + // ".server" suffix is fixed; ".meta" in the middle is fine (not a suffix) + assert_eq!(slugify_name("a.meta.server"), "a.meta_server"); + // Both are suffixes when stacked: fix ".server" → "a.meta_server" (no more suffix) + // But if the name is just ".meta": + assert_eq!(slugify_name("a.meta"), "a_meta"); + } + + #[test] + fn slugify_empty_string() { + assert_eq!(slugify_name(""), "instance"); + } + + #[test] + fn slugify_single_forbidden_char() { + // Single forbidden char → "_" → all underscores → fallback + assert_eq!(slugify_name("/"), "instance"); + assert_eq!(slugify_name("*"), "instance"); + } + + #[test] + fn slugify_tilde() { + // ~ is slugified to prevent confusion with dedup suffix ~N + assert_eq!(slugify_name("foo~1"), "foo_1"); + assert_eq!(slugify_name("~bar"), "_bar"); + assert_eq!(slugify_name("a~b~c"), "a_b_c"); + } + + #[test] + fn slugify_unicode_preserved() { + assert_eq!(slugify_name("日本語"), "日本語"); + assert_eq!(slugify_name("café"), "café"); + assert_eq!(slugify_name("émoji🎮"), "émoji🎮"); + } + + #[test] + fn slugify_mixed_valid_and_forbidden() { + assert_eq!(slugify_name("src/main:test"), "src_main_test"); + assert_eq!(slugify_name("file<1>"), "file_1_"); + // Dot preserved when safe, forbidden chars replaced + assert_eq!(slugify_name("v1.0/release"), "v1.0_release"); + } + + // ── deduplicate_name ────────────────────────────────────────────── + // + // Contract: taken_names must contain LOWERCASED entries. + + #[test] + fn dedup_no_collision() { + let taken = HashSet::new(); + assert_eq!(deduplicate_name("Foo", &taken), "Foo"); + } + + #[test] + fn dedup_single_collision() { + let taken: HashSet = ["foo".to_string()].into_iter().collect(); + assert_eq!(deduplicate_name("Foo", &taken), "Foo~1"); + } + + #[test] + fn dedup_multiple_collisions() { + let taken: HashSet = ["foo", "foo~1", "foo~2"] + .into_iter() + .map(String::from) + .collect(); + assert_eq!(deduplicate_name("Foo", &taken), "Foo~3"); + } + + #[test] + fn dedup_skips_taken_suffix() { + let taken: HashSet = ["foo", "foo~1"].into_iter().map(String::from).collect(); + assert_eq!(deduplicate_name("Foo", &taken), "Foo~2"); + } + + #[test] + fn dedup_gap_in_suffixes() { + let taken: HashSet = ["foo", "foo~2"].into_iter().map(String::from).collect(); + assert_eq!(deduplicate_name("Foo", &taken), "Foo~1"); + } + + #[test] + fn dedup_natural_vs_slug_collision() { + let taken: HashSet = ["hey_bro".to_string()].into_iter().collect(); + assert_eq!(deduplicate_name("Hey_Bro", &taken), "Hey_Bro~1"); + } + + #[test] + fn dedup_empty_taken_set() { + let taken = HashSet::new(); + assert_eq!(deduplicate_name("anything", &taken), "anything"); + } + + #[test] + fn dedup_case_insensitive() { + // "foo" is taken, "Foo" should collide (case-insensitive filesystem) + let taken: HashSet = ["foo".to_string()].into_iter().collect(); + assert_eq!(deduplicate_name("Foo", &taken), "Foo~1"); + assert_eq!(deduplicate_name("FOO", &taken), "FOO~1"); + assert_eq!(deduplicate_name("foo", &taken), "foo~1"); + } + + #[test] + fn dedup_case_only_difference() { + // Two instances: "MyScript" and "myscript" - second must get ~1 + let taken: HashSet = ["myscript".to_string()].into_iter().collect(); + assert_eq!(deduplicate_name("MyScript", &taken), "MyScript~1"); + } + + // ── name_needs_slugify ────────────────────────────────────────────── + + #[test] + fn needs_slugify_clean_names() { + assert!(!name_needs_slugify("Hello")); + assert!(!name_needs_slugify("my_module")); + assert!(!name_needs_slugify("test-123")); + } + + #[test] + fn needs_slugify_os_forbidden() { + assert!(name_needs_slugify("a/b")); + assert!(name_needs_slugify("a:b")); + assert!(name_needs_slugify("a*b")); + } + + #[test] + fn needs_slugify_tilde() { + assert!(name_needs_slugify("foo~1")); + assert!(name_needs_slugify("~bar")); + } + + #[test] + fn needs_slugify_safe_dots_allowed() { + // Dots in names that don't form dangerous suffixes are fine + assert!(!name_needs_slugify("v1.0")); + assert!(!name_needs_slugify("foo.bar")); + } + + #[test] + fn needs_slugify_dangerous_suffix() { + assert!(name_needs_slugify("foo.server")); + assert!(name_needs_slugify("foo.Server")); + assert!(name_needs_slugify("foo.meta")); + assert!(name_needs_slugify("foo.model")); + assert!(name_needs_slugify("foo.project")); + assert!(name_needs_slugify("foo.client")); + } + + #[test] + fn needs_slugify_windows_reserved() { + assert!(name_needs_slugify("CON")); + assert!(name_needs_slugify("con")); + assert!(name_needs_slugify("NUL")); + } + + #[test] + fn needs_slugify_leading_or_trailing_space() { + assert!(name_needs_slugify("hello ")); // trailing + assert!(name_needs_slugify(" hello")); // leading + assert!(name_needs_slugify("hello.")); // trailing dot + assert!(!name_needs_slugify("hello world")); // middle space is fine + } + + #[test] + fn needs_slugify_empty() { + assert!(name_needs_slugify("")); + } + + #[test] + fn needs_slugify_control_chars() { + assert!(name_needs_slugify("a\x00b")); + assert!(name_needs_slugify("tab\there")); + } + + // ── validate_file_name ──────────────────────────────────────────── + + #[test] + fn validate_clean_names() { + assert!(validate_file_name("Hello").is_ok()); + assert!(validate_file_name("my_file").is_ok()); + assert!(validate_file_name("test-123").is_ok()); + assert!(validate_file_name("日本語").is_ok()); + } + + #[test] + fn validate_rejects_forbidden_chars() { + for ch in FORBIDDEN_CHARS { + let name = format!("a{ch}b"); + assert!(validate_file_name(&name).is_err(), "should reject {ch:?}"); + } + } + + #[test] + fn validate_rejects_trailing_dot() { + assert!(validate_file_name("hello.").is_err()); + } + + #[test] + fn validate_rejects_trailing_space() { + assert!(validate_file_name("hello ").is_err()); + } + + #[test] + fn validate_rejects_windows_reserved() { + assert!(validate_file_name("CON").is_err()); + assert!(validate_file_name("NUL").is_err()); + assert!(validate_file_name("COM1").is_err()); + } + + #[test] + fn validate_rejects_control_chars() { + assert!(validate_file_name("hello\x00world").is_err()); + assert!(validate_file_name("tab\there").is_err()); + } + + #[test] + fn validate_slugified_names_always_pass() { + // Any output of slugify_name should pass validation + let nasty_inputs = [ + "CON", + "hello/world", + "a:b:c", + "test?", + "file*glob", + "trailing.", + "trailing ", + "", + "<>:\"/\\|?*", + "COM1", + "LPT9", + "foo~1", + "foo.server", + "bar.meta", + "v1.0~beta", + " leading space", + "trailing space ", + "hello world", + "a.meta.server", + ]; + for input in nasty_inputs { + let slug = slugify_name(input); + assert!( + validate_file_name(&slug).is_ok(), + "slugify_name({input:?}) = {slug:?} should pass validation" + ); + } + } + + // ── name_for_inst (new instances, no old_inst) ──────────────────── + + /// Helper: create a WeakDom with a child instance of given name and class, + /// return the dom and the child's Ref. + fn make_inst(name: &str, class: &str) -> WeakDom { + let builder = InstanceBuilder::new("DataModel") + .with_child(InstanceBuilder::new(class).with_name(name)); + WeakDom::new(builder) + } + + #[test] + fn name_for_inst_clean_name_file_middleware() { + let dom = make_inst("MyModule", "ModuleScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken = HashSet::new(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "MyModule.luau"); + assert!(!needs_meta); + } + + #[test] + fn name_for_inst_clean_name_dir_middleware() { + let dom = make_inst("MyFolder", "Folder"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken = HashSet::new(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::Dir, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "MyFolder"); + assert!(!needs_meta); + } + + #[test] + fn name_for_inst_forbidden_chars_slugified() { + let dom = make_inst("Hey/Bro", "ModuleScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken = HashSet::new(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "Hey_Bro.luau"); + assert!(needs_meta, "slug differs from real name, needs meta"); + } + + #[test] + fn name_for_inst_forbidden_chars_dir_middleware() { + let dom = make_inst("Hey:Bro", "Folder"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken = HashSet::new(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::Dir, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "Hey_Bro"); + assert!(needs_meta); + } + + #[test] + fn name_for_inst_dedup_collision() { + let dom = make_inst("Foo", "ModuleScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken: HashSet = ["foo".to_string()].into_iter().collect(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "Foo~1.luau"); + assert!(needs_meta, "deduped name differs from original"); + } + + #[test] + fn name_for_inst_dedup_dir_collision() { + let dom = make_inst("Stuff", "Folder"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken: HashSet = ["stuff".to_string()].into_iter().collect(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::Dir, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "Stuff~1"); + assert!(needs_meta); + } + + #[test] + fn name_for_inst_slug_plus_dedup() { + // Name with forbidden chars AND collision with existing slug + let dom = make_inst("Hey/Bro", "ModuleScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken: HashSet = ["hey_bro".to_string()].into_iter().collect(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "Hey_Bro~1.luau"); + assert!(needs_meta); + } + + #[test] + fn name_for_inst_server_script_extension() { + let dom = make_inst("Main", "Script"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken = HashSet::new(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::ServerScript, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "Main.server.luau"); + assert!(!needs_meta); + } + + #[test] + fn name_for_inst_client_script_extension() { + let dom = make_inst("Client", "LocalScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken = HashSet::new(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::ClientScript, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "Client.client.luau"); + assert!(!needs_meta); + } + + #[test] + fn name_for_inst_text_extension() { + let dom = make_inst("Readme", "StringValue"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken = HashSet::new(); + + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::Text, child, None, &taken).unwrap(); + assert_eq!(filename.as_ref(), "Readme.txt"); + assert!(!needs_meta); + } + + // ══════════════════════════════════════════════════════════════════ + // Comprehensive slugified-name + dedup integration tests + // + // These simulate the full name_for_inst pipeline: slugify decision, + // dedup against taken_names, filename construction, and needs_meta + // flag correctness. Every edge case, ordering, and middleware + // combination the two-way sync could encounter. + // ══════════════════════════════════════════════════════════════════ + + /// Helper: run name_for_inst with a given name, middleware, and taken set. + /// Returns (filename_string, needs_meta). + fn nfi(name: &str, mw: Middleware, taken: &[&str]) -> (String, bool) { + let dom = make_inst(name, "ModuleScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken_set: HashSet = taken.iter().map(|s| s.to_string()).collect(); + let (filename, needs_meta, _dk) = name_for_inst(mw, child, None, &taken_set).unwrap(); + (filename.into_owned(), needs_meta) + } + + // ── Dangerous suffix slugification through name_for_inst ───────── + + #[test] + fn nfi_dangerous_suffix_server_luau() { + let (f, m) = nfi("foo.server", Middleware::ModuleScript, &[]); + assert_eq!(f, "foo_server.luau"); + assert!(m, "dangerous suffix must trigger needs_meta"); + } + + #[test] + fn nfi_dangerous_suffix_client_luau() { + let (f, m) = nfi("bar.client", Middleware::ClientScript, &[]); + assert_eq!(f, "bar_client.client.luau"); + assert!(m); + } + + #[test] + fn nfi_dangerous_suffix_meta_dir() { + let (f, m) = nfi("Config.meta", Middleware::Dir, &[]); + assert_eq!(f, "Config_meta"); + assert!(m); + } + + #[test] + fn nfi_dangerous_suffix_model_json_model() { + let (f, m) = nfi("Part.model", Middleware::JsonModel, &[]); + assert_eq!(f, "Part_model.model.json5"); + assert!(m); + } + + #[test] + fn nfi_dangerous_suffix_project() { + let (f, m) = nfi("Game.project", Middleware::ModuleScript, &[]); + assert_eq!(f, "Game_project.luau"); + assert!(m); + } + + #[test] + #[allow(non_snake_case)] + fn nfi_dangerous_suffix_case_insensitive_SERVER() { + let (f, m) = nfi("test.SERVER", Middleware::ModuleScript, &[]); + assert_eq!(f, "test_SERVER.luau"); + assert!(m); + } + + #[test] + #[allow(non_snake_case)] + fn nfi_dangerous_suffix_case_insensitive_Meta() { + let (f, m) = nfi("stuff.Meta", Middleware::Dir, &[]); + assert_eq!(f, "stuff_Meta"); + assert!(m); + } + + // ── Safe dots pass through ─────────────────────────────────────── + + #[test] + fn nfi_safe_dot_v1_0() { + let (f, m) = nfi("v1.0", Middleware::ModuleScript, &[]); + assert_eq!(f, "v1.0.luau"); + assert!(!m); + } + + #[test] + fn nfi_safe_dot_hello_world() { + let (f, m) = nfi("hello.world", Middleware::ModuleScript, &[]); + assert_eq!(f, "hello.world.luau"); + assert!(!m); + } + + #[test] + fn nfi_safe_dot_dir() { + let (f, m) = nfi("Release.1", Middleware::Dir, &[]); + assert_eq!(f, "Release.1"); + assert!(!m); + } + + // ── Spaces in names ────────────────────────────────────────────── + + #[test] + fn nfi_middle_space_preserved() { + let (f, m) = nfi("My Module", Middleware::ModuleScript, &[]); + assert_eq!(f, "My Module.luau"); + assert!(!m, "middle space is valid, no meta needed"); + } + + #[test] + fn nfi_leading_space_slugified() { + let (f, m) = nfi(" Leading", Middleware::ModuleScript, &[]); + assert_eq!(f, "Leading.luau"); + assert!(m, "leading space stripped → slug differs"); + } + + #[test] + fn nfi_trailing_space_slugified() { + let (f, m) = nfi("Trailing ", Middleware::ModuleScript, &[]); + assert_eq!(f, "Trailing.luau"); + assert!(m); + } + + // ── Tilde in names ─────────────────────────────────────────────── + + #[test] + fn nfi_tilde_in_name_slugified() { + let (f, m) = nfi("Data~1", Middleware::ModuleScript, &[]); + assert_eq!(f, "Data_1.luau"); + assert!(m); + } + + #[test] + fn nfi_tilde_in_dir_name() { + let (f, m) = nfi("Cache~old", Middleware::Dir, &[]); + assert_eq!(f, "Cache_old"); + assert!(m); + } + + // ── OS-forbidden chars ─────────────────────────────────────────── + + #[test] + fn nfi_slash_in_name() { + let (f, m) = nfi("src/main", Middleware::ModuleScript, &[]); + assert_eq!(f, "src_main.luau"); + assert!(m); + } + + #[test] + fn nfi_colon_in_name() { + let (f, m) = nfi("Drive:C", Middleware::Dir, &[]); + assert_eq!(f, "Drive_C"); + assert!(m); + } + + #[test] + fn nfi_question_mark() { + let (f, m) = nfi("What?", Middleware::ServerScript, &[]); + assert_eq!(f, "What_.server.luau"); + assert!(m); + } + + #[test] + fn nfi_asterisk() { + let (f, m) = nfi("glob*", Middleware::ModuleScript, &[]); + assert_eq!(f, "glob_.luau"); + assert!(m); + } + + #[test] + fn nfi_angle_brackets() { + let (f, m) = nfi("", Middleware::ModuleScript, &[]); + assert_eq!(f, "_init_.luau"); + assert!(m); + } + + #[test] + fn nfi_pipe() { + let (f, m) = nfi("A|B", Middleware::Dir, &[]); + assert_eq!(f, "A_B"); + assert!(m); + } + + #[test] + fn nfi_backslash() { + let (f, m) = nfi("path\\to", Middleware::ModuleScript, &[]); + assert_eq!(f, "path_to.luau"); + assert!(m); + } + + #[test] + fn nfi_double_quote() { + let (f, m) = nfi("say\"hi\"", Middleware::ModuleScript, &[]); + assert_eq!(f, "say_hi_.luau"); + assert!(m); + } + + // ── Windows reserved names ─────────────────────────────────────── + + #[test] + fn nfi_con() { + let (f, m) = nfi("CON", Middleware::ModuleScript, &[]); + assert_eq!(f, "CON_.luau"); + assert!(m); + } + + #[test] + fn nfi_nul_dir() { + let (f, m) = nfi("NUL", Middleware::Dir, &[]); + assert_eq!(f, "NUL_"); + assert!(m); + } + + #[test] + fn nfi_com1_csv() { + let (f, m) = nfi("COM1", Middleware::Csv, &[]); + assert_eq!(f, "COM1_.csv"); + assert!(m); + } + + // ── Empty / fallback names ─────────────────────────────────────── + + #[test] + fn nfi_empty_name() { + let (f, m) = nfi("", Middleware::ModuleScript, &[]); + assert_eq!(f, "instance.luau"); + assert!(m); + } + + #[test] + fn nfi_all_forbidden() { + let (f, m) = nfi("<>:\"/\\|?*", Middleware::Dir, &[]); + assert_eq!(f, "instance"); + assert!(m); + } + + // ── Dedup with slugified names (collision scenarios) ───────────── + + // NOTE: deduplicate_name compares the bare slug against taken_names. + // In real syncback (dir.rs), taken_names stores full filenames, so file- + // level dedup only works for directory middleware where filename == slug. + // For file middleware, the duplicate-name pre-filter handles collisions. + // These tests use bare slugs in taken_names to verify the dedup logic. + + #[test] + fn nfi_slug_collides_with_existing_dir() { + // "Hey/Bro" slugifies to "Hey_Bro", which is already taken as a dir name + let (f, m) = nfi("Hey/Bro", Middleware::Dir, &["hey_bro"]); + assert_eq!(f, "Hey_Bro~1"); + assert!(m); + } + + #[test] + fn nfi_slug_collides_with_natural_dir() { + // Dir "Hey_Bro" exists naturally, then "Hey:Bro" slugifies to same + let (f, m) = nfi("Hey:Bro", Middleware::Dir, &["hey_bro"]); + assert_eq!(f, "Hey_Bro~1"); + assert!(m); + } + + #[test] + fn nfi_two_slugs_collide_dir() { + // Both "A/B" and "A:B" slugify to "A_B". First taken, second gets ~1 + let (f, m) = nfi("A:B", Middleware::Dir, &["a_b"]); + assert_eq!(f, "A_B~1"); + assert!(m); + } + + #[test] + fn nfi_three_way_collision_dir() { + let (f, _) = nfi("X/Y", Middleware::Dir, &["x_y", "x_y~1"]); + assert_eq!(f, "X_Y~2"); + } + + #[test] + fn nfi_clean_name_dedup_still_needs_meta_dir() { + // Clean name "Foo", but "foo" is already taken → dedup adds ~1 → needs meta + let (f, m) = nfi("Foo", Middleware::Dir, &["foo"]); + assert_eq!(f, "Foo~1"); + assert!( + m, + "dedup suffix means filesystem name differs from instance name" + ); + } + + #[test] + fn nfi_case_collision_dir() { + let (f, m) = nfi("Assets", Middleware::Dir, &["assets"]); + assert_eq!(f, "Assets~1"); + assert!(m); + } + + #[test] + fn nfi_slug_collision_file_middleware() { + // For file middleware, dedup compares bare slug against taken. + // Using bare names to verify the dedup logic works. + let (f, m) = nfi("Hey/Bro", Middleware::ModuleScript, &["hey_bro"]); + assert_eq!(f, "Hey_Bro~1.luau"); + assert!(m); + } + + // ── Ordering simulation: multiple siblings processed sequentially ─ + + /// Simulates processing N siblings in order, accumulating taken_names. + /// Uses Dir middleware for all to test dedup correctly (dir filenames + /// ARE the bare slug, matching what deduplicate_name compares against). + fn process_siblings_dir(siblings: &[&str]) -> Vec<(String, String, bool)> { + let mut taken: HashSet = HashSet::new(); + let mut results = Vec::new(); + for &name in siblings { + let dom = make_inst(name, "Folder"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let (filename, needs_meta, dedup_key) = + name_for_inst(Middleware::Dir, child, None, &taken).unwrap(); + taken.insert(dedup_key.to_lowercase()); + results.push((name.to_string(), filename.into_owned(), needs_meta)); + } + results + } + + #[test] + fn ordering_two_identical_names() { + let r = process_siblings_dir(&["Foo", "Foo"]); + assert_eq!(r[0].1, "Foo"); + assert!(!r[0].2); + assert_eq!(r[1].1, "Foo~1"); + assert!(r[1].2); + } + + #[test] + fn ordering_three_identical_names() { + let r = process_siblings_dir(&["X", "X", "X"]); + assert_eq!(r[0].1, "X"); + assert_eq!(r[1].1, "X~1"); + assert_eq!(r[2].1, "X~2"); + } + + #[test] + fn ordering_natural_then_slugified_collision() { + // "Hey_Bro" goes first (natural), then "Hey/Bro" slugifies to same + let r = process_siblings_dir(&["Hey_Bro", "Hey/Bro"]); + assert_eq!(r[0].1, "Hey_Bro"); + assert!(!r[0].2); + assert_eq!(r[1].1, "Hey_Bro~1"); + assert!(r[1].2); + } + + #[test] + fn ordering_slugified_then_natural_collision() { + // "Hey/Bro" goes first (slugified to "Hey_Bro"), then natural "Hey_Bro" + let r = process_siblings_dir(&["Hey/Bro", "Hey_Bro"]); + assert_eq!(r[0].1, "Hey_Bro"); + assert!(r[0].2); + assert_eq!(r[1].1, "Hey_Bro~1"); + assert!(r[1].2, "dedup suffix → needs meta even for natural name"); + } + + #[test] + fn ordering_case_variants() { + let r = process_siblings_dir(&["Script", "script", "SCRIPT"]); + assert_eq!(r[0].1, "Script"); + assert!(!r[0].2); + assert_eq!(r[1].1, "script~1"); + assert!(r[1].2); + assert_eq!(r[2].1, "SCRIPT~2"); + assert!(r[2].2); + } + + #[test] + fn ordering_mixed_forbidden_chars_same_slug() { + // All three slugify to "A_B" + let r = process_siblings_dir(&["A/B", "A:B", "A*B"]); + assert_eq!(r[0].1, "A_B"); + assert_eq!(r[1].1, "A_B~1"); + assert_eq!(r[2].1, "A_B~2"); + assert!(r[0].2); + assert!(r[1].2); + assert!(r[2].2); + } + + #[test] + fn ordering_dangerous_suffix_then_safe() { + // "test.server" slugifies to "test_server", "test" is clean + let r = process_siblings_dir(&["test.server", "test"]); + assert_eq!(r[0].1, "test_server"); + assert!(r[0].2); + assert_eq!(r[1].1, "test"); + assert!(!r[1].2); + } + + #[test] + fn ordering_windows_reserved_then_normal() { + // "CON" slugifies to "CON_", then natural "CON_" collides + let r = process_siblings_dir(&["CON", "CON_"]); + assert_eq!(r[0].1, "CON_"); + assert!(r[0].2); + assert_eq!(r[1].1, "CON_~1"); + assert!(r[1].2); + } + + #[test] + fn ordering_five_siblings_complex() { + let r = process_siblings_dir(&[ + "Utils", // first claim + "utils", // case collision with Utils + "Utils", // exact collision (already taken + ~1 taken) + "UTILS", // case collision + "Uti/ls", // different slug "Uti_ls", no collision + ]); + assert_eq!(r[0].1, "Utils"); + assert!(!r[0].2); + assert_eq!(r[1].1, "utils~1"); + assert!(r[1].2); + assert_eq!(r[2].1, "Utils~2"); + assert!(r[2].2); + assert_eq!(r[3].1, "UTILS~3"); + assert!(r[3].2); + assert_eq!(r[4].1, "Uti_ls"); + assert!(r[4].2); + } + + #[test] + fn ordering_six_way_slug_pileup() { + // All six slugify to "X_Y" as dirs + let r = process_siblings_dir(&["X/Y", "X:Y", "X*Y", "X?Y", "XY"]); + assert_eq!(r[0].1, "X_Y"); + assert_eq!(r[1].1, "X_Y~1"); + assert_eq!(r[2].1, "X_Y~2"); + assert_eq!(r[3].1, "X_Y~3"); + assert_eq!(r[4].1, "X_Y~4"); + assert_eq!(r[5].1, "X_Y~5"); + } + + #[test] + fn ordering_tilde_in_name_vs_dedup_suffix() { + // "Foo~1" (tilde slugified to "Foo_1") vs dedup suffix "Foo~1" + // The slugified "Foo~1" → "Foo_1" which is different from dedup "Foo~1" + let r = process_siblings_dir(&["Foo", "Foo", "Foo~1"]); + assert_eq!(r[0].1, "Foo"); + assert_eq!(r[1].1, "Foo~1"); // dedup suffix + assert_eq!(r[2].1, "Foo_1"); // tilde was slugified to _ + assert!(r[2].2); + } + + #[test] + fn ordering_spaces_and_collisions() { + let r = process_siblings_dir(&[ + " Leading", // leading space stripped → "Leading" + "Leading", // collides with stripped version + "Leading ", // trailing space stripped → "Leading" → collides + ]); + assert_eq!(r[0].1, "Leading"); + assert!(r[0].2, "leading space was stripped"); + assert_eq!(r[1].1, "Leading~1"); + assert!(r[1].2); + assert_eq!(r[2].1, "Leading~2"); + assert!(r[2].2); + } + + // ── Every middleware type with slugified name ───────────────────── + + #[test] + fn nfi_every_middleware_with_slash() { + let cases: &[(&str, Middleware, &str)] = &[ + ("a/b", Middleware::ModuleScript, "a_b.luau"), + ("a/b", Middleware::ServerScript, "a_b.server.luau"), + ("a/b", Middleware::ClientScript, "a_b.client.luau"), + ("a/b", Middleware::PluginScript, "a_b.plugin.luau"), + ("a/b", Middleware::LocalScript, "a_b.local.luau"), + ("a/b", Middleware::LegacyScript, "a_b.legacy.luau"), + ("a/b", Middleware::Csv, "a_b.csv"), + ("a/b", Middleware::JsonModel, "a_b.model.json5"), + ("a/b", Middleware::Text, "a_b.txt"), + ("a/b", Middleware::Rbxm, "a_b.rbxm"), + ("a/b", Middleware::Rbxmx, "a_b.rbxmx"), + ("a/b", Middleware::Toml, "a_b.toml"), + ("a/b", Middleware::Yaml, "a_b.yml"), + ("a/b", Middleware::Dir, "a_b"), + ("a/b", Middleware::ServerScriptDir, "a_b"), + ("a/b", Middleware::ClientScriptDir, "a_b"), + ("a/b", Middleware::ModuleScriptDir, "a_b"), + ]; + for &(name, mw, expected) in cases { + let (f, m) = nfi(name, mw, &[]); + assert_eq!(f, expected, "name={name:?} mw={mw:?}"); + assert!(m, "name={name:?} mw={mw:?} should need meta"); + } + } + + // ── Stress: validate_file_name accepts every slugify output ────── + + #[test] + fn slugify_output_always_valid_filesystem_name() { + let long_name = "A".repeat(255); + let corpus = [ + "", + " ", + " ", + ".", + "..", + "...", + ". .", + " . ", + "/", + "\\", + "*", + "?", + "<", + ">", + "|", + ":", + "\"", + "~", + "~1", + "~99", + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM9", + "LPT1", + "LPT9", + "con", + "prn", + "aux", + "nul", + "com1", + "foo.server", + "foo.client", + "foo.plugin", + "foo.local", + "foo.legacy", + "foo.meta", + "foo.model", + "foo.project", + "foo.Server", + "foo.CLIENT", + "foo.Meta", + "a.meta.server", + "x.model.project.client", + "hello world", + " hello", + "hello ", + " hello ", + "日本語", + "café", + "émoji🎮", + "v1.0", + "foo.bar", + "a.b.c.d", + "<>:\"/\\|?*~. ", + long_name.as_str(), + "\x00", + "\t", + "\n", + "\r", + ]; + for input in corpus { + let slug = slugify_name(input); + assert!( + validate_file_name(&slug).is_ok(), + "slugify_name({input:?}) = {slug:?} failed validate_file_name" + ); + assert!( + !slug.is_empty(), + "slugify_name({input:?}) should never be empty" + ); + } + } + + #[test] + fn name_needs_slugify_implies_slug_differs() { + // If name_needs_slugify returns true, slugify_name MUST return + // something different from the input (otherwise we'd set needs_meta + // but the name and slug would be the same — pointless meta field). + let corpus = [ + "CON", + "a/b", + "foo~1", + "foo.server", + " leading", + "trailing ", + "hello.", + "", + "\t", + "COM1", + ]; + for input in corpus { + if name_needs_slugify(input) { + let slug = slugify_name(input); + assert_ne!( + slug, input, + "name_needs_slugify({input:?})=true but slugify produced identical output" + ); + } + } + } + + #[test] + fn name_needs_slugify_false_means_slug_is_identity() { + // If name_needs_slugify returns false, slugify_name should return + // the input unchanged (no unnecessary transformation). + let corpus = [ + "Hello", + "my_module", + "test-123", + "v1.0", + "foo.bar", + "hello world", + "日本語", + "café", + ]; + for input in corpus { + assert!( + !name_needs_slugify(input), + "{input:?} should not need slugify" + ); + assert_eq!( + slugify_name(input), + input, + "slugify_name should be identity for {input:?}" + ); + } + } + + // ══════════════════════════════════════════════════════════════════ + // DISK-SEEDED TAKEN NAMES TESTS + // + // These simulate the fix where taken_names is pre-seeded from + // existing directory contents. Before the fix, taken_names was + // empty and new instances could overwrite existing files. + // + // Every test in this section would have FAILED before the fix. + // ══════════════════════════════════════════════════════════════════ + + /// Simulates dir.rs behavior AFTER fix #3: seed taken_names from + /// "on-disk" entries, then process new children sequentially. + fn process_new_children_with_disk_seed( + disk_entries: &[&str], + new_children: &[(&str, Middleware)], + ) -> Vec<(String, String, bool)> { + // Seed from bare slugs (simulates tree-based seeding) + let mut taken: HashSet = disk_entries.iter().map(|e| e.to_lowercase()).collect(); + let mut results = Vec::new(); + for &(name, mw) in new_children { + let dom = make_inst(name, "ModuleScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let (filename, needs_meta, dedup_key) = name_for_inst(mw, child, None, &taken).unwrap(); + taken.insert(dedup_key.to_lowercase()); + results.push((name.to_string(), filename.into_owned(), needs_meta)); + } + results + } + + #[test] + fn disk_seed_prevents_overwrite_of_existing_dir() { + // "Utils" directory exists on disk. New instance "Utils" (dir) + // must get "Utils~1". + let r = process_new_children_with_disk_seed(&["utils"], &[("Utils", Middleware::Dir)]); + assert_eq!(r[0].1, "Utils~1"); + assert!(r[0].2); + } + + #[test] + fn disk_seed_prevents_overwrite_of_existing_dir_case_insensitive() { + // "SCRIPTS" directory on disk, new instance "Scripts" (dir) must dedup. + let r = process_new_children_with_disk_seed(&["scripts"], &[("Scripts", Middleware::Dir)]); + assert_eq!(r[0].1, "Scripts~1"); + assert!(r[0].2); + } + + #[test] + fn disk_seed_slug_collision_with_existing_dir() { + // "Hey_Bro" directory on disk. New instance "Hey/Bro" slugifies + // to "Hey_Bro" — must dedup to "Hey_Bro~1". + let r = process_new_children_with_disk_seed(&["hey_bro"], &[("Hey/Bro", Middleware::Dir)]); + assert_eq!(r[0].1, "Hey_Bro~1"); + assert!(r[0].2); + } + + #[test] + fn disk_seed_multiple_existing_dirs() { + // Directory has: Utils/, Config/, Shared/ + // New dir children: Utils (collision!), Config (collision!), NewThing (clean) + let r = process_new_children_with_disk_seed( + &["utils", "config", "shared"], + &[ + ("Utils", Middleware::Dir), + ("Config", Middleware::Dir), + ("NewThing", Middleware::Dir), + ], + ); + assert_eq!(r[0].1, "Utils~1", "Utils collides with utils/ on disk"); + assert!(r[0].2); + assert_eq!(r[1].1, "Config~1", "Config collides with config/ on disk"); + assert!(r[1].2); + assert_eq!(r[2].1, "NewThing", "NewThing is clean"); + assert!(!r[2].2); + } + + #[test] + fn disk_seed_existing_dir_plus_meta_and_files() { + // Directory contains: Stuff/ (dir), Stuff.meta.json5, Other.luau + // New "Stuff" dir must dedup against "stuff" (the dir). + let r = process_new_children_with_disk_seed( + &["stuff", "stuff.meta.json5", "other.luau"], + &[("Stuff", Middleware::Dir)], + ); + assert_eq!(r[0].1, "Stuff~1"); + assert!(r[0].2); + } + + #[test] + fn disk_seed_file_middleware_collides_with_bare_slug() { + // taken_names now uses bare slugs (dedup_keys), so file-format + // dedup works correctly. "mymodule" in taken matches slug "MyModule". + let r = process_new_children_with_disk_seed( + &["mymodule"], + &[("MyModule", Middleware::ModuleScript)], + ); + assert_eq!(r[0].1, "MyModule~1.luau"); + assert!(r[0].2, "dedup suffix means needs meta"); + } + + #[test] + fn disk_seed_file_middleware_sibling_slug_collision_deduped() { + // Two file-middleware children whose names slugify to the same base. + // Since taken_names accumulates dedup_keys (bare slugs), the second + // child correctly detects the collision and gets ~1. + let r = process_new_children_with_disk_seed( + &[], + &[ + ("A/B", Middleware::ModuleScript), + ("A:B", Middleware::ModuleScript), + ], + ); + assert_eq!(r[0].1, "A_B.luau"); + assert!(r[0].2); + assert_eq!(r[1].1, "A_B~1.luau"); + assert!(r[1].2); + } + + #[test] + fn disk_seed_gitkeep_doesnt_interfere() { + // .gitkeep files are common in empty dirs. New "gitkeep" instance + // shouldn't collide (hidden files start with dot, regular names don't). + // But ".gitkeep" lowered is ".gitkeep" — if we have a bizarre instance + // named ".gitkeep", it won't be slugified (no forbidden chars) but would + // collide. Testing that the seeding doesn't break normal flow. + let r = process_new_children_with_disk_seed( + &[".gitkeep"], + &[("MyModule", Middleware::ModuleScript)], + ); + assert_eq!(r[0].1, "MyModule.luau"); + assert!(!r[0].2, "no collision with .gitkeep"); + } + + #[test] + fn disk_seed_init_file_doesnt_prevent_new_siblings() { + // Parent dir has "init.luau". New children should work fine. + let r = process_new_children_with_disk_seed( + &["init.luau"], + &[ + ("Child1", Middleware::ModuleScript), + ("Child2", Middleware::ServerScript), + ], + ); + assert_eq!(r[0].1, "Child1.luau"); + assert!(!r[0].2); + assert_eq!(r[1].1, "Child2.server.luau"); + assert!(!r[1].2); + } + + #[test] + fn disk_seed_dedup_suffix_dir_already_on_disk() { + // Pathological: "Foo" AND "Foo~1" directories already on disk. + // New "Foo" dir must skip both and land on "Foo~2". + let r = process_new_children_with_disk_seed(&["foo", "foo~1"], &[("Foo", Middleware::Dir)]); + assert_eq!(r[0].1, "Foo~2"); + assert!(r[0].2); + } + + #[test] + fn disk_seed_dedup_chain_on_disk() { + // "A_B", "A_B~1", "A_B~2" already on disk. New "A/B" (slug "A_B") + // must land on "A_B~3". + let r = process_new_children_with_disk_seed( + &["a_b", "a_b~1", "a_b~2"], + &[("A/B", Middleware::Dir)], + ); + assert_eq!(r[0].1, "A_B~3"); + assert!(r[0].2); + } + + // ══════════════════════════════════════════════════════════════════ + // SIBLING DEDUP TESTS (project.rs fix #2 simulation) + // + // These simulate the fix where siblings under the same parent + // share a single taken_names set. Before the fix, each sibling + // got an independent empty HashSet and could collide. + // + // Every test in this section would have FAILED before the fix. + // ══════════════════════════════════════════════════════════════════ + + /// Simulates project.rs behavior AFTER fix #2: shared taken_names + /// across siblings, seeded from disk, accumulated per-child. + fn process_project_siblings( + disk_entries: &[&str], + siblings: &[(&str, Middleware)], + ) -> Vec<(String, String, bool)> { + let mut taken: HashSet = disk_entries.iter().map(|e| e.to_lowercase()).collect(); + let mut results = Vec::new(); + for &(name, mw) in siblings { + let dom = make_inst(name, "ModuleScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let (filename, needs_meta, dedup_key) = name_for_inst(mw, child, None, &taken).unwrap(); + taken.insert(dedup_key.to_lowercase()); + results.push((name.to_string(), filename.into_owned(), needs_meta)); + } + results + } + + #[test] + fn project_siblings_same_slug_different_names() { + // Two new project children: "A/B" and "A:B" both slug to "A_B". + // Before fix: both got "A_B" path (collision!). + // After fix: second gets "A_B~1". + let r = + process_project_siblings(&[], &[("A/B", Middleware::Dir), ("A:B", Middleware::Dir)]); + assert_eq!(r[0].1, "A_B"); + assert!(r[0].2); + assert_eq!(r[1].1, "A_B~1"); + assert!(r[1].2); + } + + #[test] + fn project_siblings_natural_vs_slug_collision_dir() { + // "Hey_Bro" (natural) and "Hey/Bro" (slug "Hey_Bro") as dir children. + // Dir middleware: bare slug IS the filename → dedup works. + let r = process_project_siblings( + &[], + &[("Hey_Bro", Middleware::Dir), ("Hey/Bro", Middleware::Dir)], + ); + assert_eq!(r[0].1, "Hey_Bro"); + assert!(!r[0].2); + assert_eq!(r[1].1, "Hey_Bro~1"); + assert!(r[1].2); + } + + #[test] + fn project_siblings_three_way_slug_collision() { + // Three siblings all slug to "X_Y" — must get X_Y, X_Y~1, X_Y~2. + let r = process_project_siblings( + &[], + &[ + ("X/Y", Middleware::Dir), + ("X:Y", Middleware::Dir), + ("X*Y", Middleware::Dir), + ], + ); + assert_eq!(r[0].1, "X_Y"); + assert_eq!(r[1].1, "X_Y~1"); + assert_eq!(r[2].1, "X_Y~2"); + } + + #[test] + fn project_siblings_disk_plus_siblings_collision_dir() { + // "Helper" directory already on disk. Two new dir siblings: "Helper" x2. + // First → "Helper~1" (disk has "helper"). + // Second → "Helper~2" (both disk and first sibling taken). + let r = process_project_siblings( + &["helper"], + &[("Helper", Middleware::Dir), ("Helper", Middleware::Dir)], + ); + assert_eq!(r[0].1, "Helper~1"); + assert!(r[0].2); + assert_eq!(r[1].1, "Helper~2"); + assert!(r[1].2); + } + + #[test] + fn project_siblings_case_collision_chain() { + // Case variants: "Test", "test", "TEST", "tEsT" — all collide case-insensitively. + let r = process_project_siblings( + &[], + &[ + ("Test", Middleware::Dir), + ("test", Middleware::Dir), + ("TEST", Middleware::Dir), + ("tEsT", Middleware::Dir), + ], + ); + assert_eq!(r[0].1, "Test"); + assert!(!r[0].2); + assert_eq!(r[1].1, "test~1"); + assert!(r[1].2); + assert_eq!(r[2].1, "TEST~2"); + assert!(r[2].2); + assert_eq!(r[3].1, "tEsT~3"); + assert!(r[3].2); + } + + // ══════════════════════════════════════════════════════════════════ + // NIGHTMARE EDGE CASES + // + // Tests that combine multiple transformation layers in ways that + // should make your eyes water. Each one targets a specific + // interaction between slugify, dedup, dangerous suffixes, case + // folding, Windows reserved names, and unicode. + // ══════════════════════════════════════════════════════════════════ + + #[test] + fn nightmare_con_slash_prn_sibling_collision() { + // "CON" → "CON_", "PRN" → "PRN_" — different slugs, no collision. + // But "CON/" → "CON_" collides with "CON" → "CON_". + let r = process_siblings_dir(&["CON", "CON/"]); + assert_eq!(r[0].1, "CON_"); + assert!(r[0].2); + assert_eq!(r[1].1, "CON_~1"); + assert!(r[1].2); + } + + #[test] + fn nightmare_dangerous_suffix_cascade_collision() { + // "foo.server" → "foo_server", "foo/server" → "foo_server" — collision! + let r = process_siblings_dir(&["foo.server", "foo/server"]); + assert_eq!(r[0].1, "foo_server"); + assert!(r[0].2); + assert_eq!(r[1].1, "foo_server~1"); + assert!(r[1].2); + } + + #[test] + fn nightmare_tilde_looks_like_dedup_but_gets_slugified() { + // "Foo~1" has tilde slugified to "Foo_1". Then real "Foo" gets + // dedup "Foo~1". These should NOT collide because tilde-in-name + // becomes underscore, but dedup adds actual tilde. + let r = process_siblings_dir(&["Foo~1", "Foo", "Foo"]); + assert_eq!(r[0].1, "Foo_1", "tilde slugified"); + assert_eq!(r[1].1, "Foo", "clean, no collision"); + assert_eq!(r[2].1, "Foo~1", "dedup suffix, different from Foo_1"); + } + + #[test] + fn nightmare_dedup_suffix_avoids_slugified_tilde_name() { + // Process "Foo", then "Foo" again (dedup to "Foo~1"), then "Foo~1" + // (tilde slugified to "Foo_1"). The dedup "Foo~1" and slug "Foo_1" + // should coexist. + let r = process_siblings_dir(&["Foo", "Foo", "Foo~1"]); + assert_eq!(r[0].1, "Foo"); + assert_eq!(r[1].1, "Foo~1"); + assert_eq!(r[2].1, "Foo_1"); + // All three have distinct lowercased names + let names: HashSet = r.iter().map(|(_, f, _)| f.to_lowercase()).collect(); + assert_eq!(names.len(), 3, "all three filenames must be distinct"); + } + + #[test] + fn nightmare_unicode_plus_forbidden_chars() { + // "カフェ/Bar" → "カフェ_Bar", "カフェ:Bar" → "カフェ_Bar" — collision + let r = process_siblings_dir(&["カフェ/Bar", "カフェ:Bar"]); + assert_eq!(r[0].1, "カフェ_Bar"); + assert_eq!(r[1].1, "カフェ_Bar~1"); + } + + #[test] + fn nightmare_emoji_name_preserved() { + // Emoji names are not forbidden chars — they pass through. + let r = process_siblings_dir(&["🎮 Games", "🎮 Games"]); + assert_eq!(r[0].1, "🎮 Games"); + assert!(!r[0].2, "emoji name is clean"); + assert_eq!(r[1].1, "🎮 Games~1"); + assert!(r[1].2); + } + + #[test] + fn nightmare_windows_reserved_then_slug_that_matches() { + // "CON" → "CON_", then natural "CON_" collides. + // Then "con" → "con_" collides case-insensitively with "CON_". + let r = process_siblings_dir(&["CON", "CON_", "con"]); + assert_eq!(r[0].1, "CON_"); + assert!(r[0].2); + assert_eq!(r[1].1, "CON_~1"); + assert!(r[1].2, "natural CON_ collides with reserved CON's slug"); + assert_eq!(r[2].1, "con_~2"); + assert!(r[2].2); + } + + #[test] + fn nightmare_trailing_dot_chain() { + // "test." → strip trailing dot → "test", "test" → "test" collision! + let r = process_siblings_dir(&["test.", "test"]); + assert_eq!(r[0].1, "test"); + assert!(r[0].2, "trailing dot stripped → slug differs"); + assert_eq!(r[1].1, "test~1"); + assert!(r[1].2); + } + + #[test] + fn nightmare_leading_space_chain() { + // " A" → "A", "A" → "A" collision, " A" → "A" triple collision + let r = process_siblings_dir(&[" A", "A", " A"]); + assert_eq!(r[0].1, "A"); + assert!(r[0].2, "leading space stripped"); + assert_eq!(r[1].1, "A~1"); + assert!(r[1].2); + assert_eq!(r[2].1, "A~2"); + assert!(r[2].2); + } + + #[test] + fn nightmare_all_reserved_names_siblings() { + // Every Windows reserved name as a sibling — all get "_" suffix, + // and they should NOT collide with each other since the base names differ. + let reserved = [ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", + "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ]; + let r = process_siblings_dir(&reserved); + let names: HashSet = r.iter().map(|(_, f, _)| f.to_lowercase()).collect(); + assert_eq!( + names.len(), + reserved.len(), + "every reserved name should produce a unique slug" + ); + for (_, filename, needs_meta) in &r { + assert!(needs_meta, "{filename} should need meta"); + assert!( + validate_file_name(filename).is_ok(), + "{filename} should be valid" + ); + } + } + + #[test] + fn nightmare_dangerous_suffix_every_variant() { + // All 8 dangerous suffixes as sibling names, each unique slug. + let siblings: Vec = DANGEROUS_SUFFIXES + .iter() + .map(|s| format!("foo{s}")) + .collect(); + let r = process_siblings_dir(&siblings.iter().map(|s| s.as_str()).collect::>()); + let filenames: HashSet = r.iter().map(|(_, f, _)| f.to_lowercase()).collect(); + assert_eq!( + filenames.len(), + DANGEROUS_SUFFIXES.len(), + "each dangerous suffix variant should produce a unique filename" + ); + } + + #[test] + fn nightmare_mixed_middleware_dir_then_file() { + // "Shared" as Dir (filename "Shared") then ModuleScript. + // Dir filename "Shared" → taken gets "shared". + // ModuleScript dedup checks "shared" → MATCH → dedup to "Shared~1". + // Then final filename becomes "Shared~1.luau". + let r = process_new_children_with_disk_seed( + &[], + &[ + ("Shared", Middleware::Dir), + ("Shared", Middleware::ModuleScript), + ], + ); + assert_eq!(r[0].1, "Shared"); + assert!(!r[0].2); + // File middleware bare slug "Shared" matches dir entry "shared" in taken. + assert_eq!(r[1].1, "Shared~1.luau"); + assert!(r[1].2); + } + + #[test] + fn nightmare_mixed_middleware_file_then_dir() { + // Reverse order: ModuleScript first, Dir second. + // ModuleScript "Shared" → dedup_key "shared" → taken gets "shared" + // Dir "Shared" → slug "Shared" → dedup checks "shared" → MATCH + // (dedup_key-based taken catches this correctly) → "Shared~1" + let r = process_new_children_with_disk_seed( + &[], + &[ + ("Shared", Middleware::ModuleScript), + ("Shared", Middleware::Dir), + ], + ); + assert_eq!(r[0].1, "Shared.luau"); + assert!(!r[0].2); + // Dir slug "Shared" collides with dedup_key "shared" from the ModuleScript + assert_eq!(r[1].1, "Shared~1"); + assert!(r[1].2, "dedup suffix means different from instance name"); + } + + #[test] + fn nightmare_10_way_slug_pileup_with_disk_seed() { + // "A_B" already on disk. Then 9 new instances all slugifying to "A_B". + let new_children: Vec<(&str, Middleware)> = vec![ + ("A/B", Middleware::Dir), + ("A:B", Middleware::Dir), + ("A*B", Middleware::Dir), + ("A?B", Middleware::Dir), + ("AB", Middleware::Dir), + ("A|B", Middleware::Dir), + ("A\\B", Middleware::Dir), + ("A\"B", Middleware::Dir), + ]; + let r = process_new_children_with_disk_seed(&["a_b"], &new_children); + for (i, (_, filename, needs_meta)) in r.iter().enumerate() { + let expected = if i == 0 { + "A_B~1".to_string() + } else { + format!("A_B~{}", i + 1) + }; + assert_eq!(filename, &expected, "child {i}"); + assert!(needs_meta, "child {i} needs meta"); + } + } + + #[test] + fn nightmare_empty_name_siblings() { + // Multiple instances with empty names. All slugify to "instance". + let r = process_siblings_dir(&["", "", ""]); + assert_eq!(r[0].1, "instance"); + assert_eq!(r[1].1, "instance~1"); + assert_eq!(r[2].1, "instance~2"); + } + + #[test] + fn nightmare_all_forbidden_chars_name_siblings() { + // Names that are entirely forbidden chars → all become "instance". + let r = process_siblings_dir(&["<>", ":/", "?*", "|\\", "\"~"]); + assert_eq!(r[0].1, "instance"); + assert_eq!(r[1].1, "instance~1"); + assert_eq!(r[2].1, "instance~2"); + assert_eq!(r[3].1, "instance~3"); + assert_eq!(r[4].1, "instance~4"); + } + + #[test] + fn nightmare_control_chars_with_normal_text() { + // "Hello\x00World" → "Hello_World", "Hello/World" → "Hello_World" + let r = process_siblings_dir(&["Hello\x00World", "Hello/World"]); + assert_eq!(r[0].1, "Hello_World"); + assert_eq!(r[1].1, "Hello_World~1"); + } + + #[test] + fn nightmare_long_dedup_chain_gap_filling() { + // "Foo", "Foo~1", "Foo~3" on disk (gap at ~2). + // New "Foo" should fill the gap and get "Foo~2". + let r = process_new_children_with_disk_seed( + &["foo", "foo~1", "foo~3"], + &[("Foo", Middleware::Dir)], + ); + assert_eq!(r[0].1, "Foo~2", "should fill gap at ~2"); + } + + #[test] + fn nightmare_meta_suffix_collision_with_dangerous() { + // "init.meta" → dangerous suffix ".meta" → "init_meta" + // "init/meta" → slug "init_meta" → collision! + let r = process_siblings_dir(&["init.meta", "init/meta"]); + assert_eq!(r[0].1, "init_meta"); + assert_eq!(r[1].1, "init_meta~1"); + } + + #[test] + fn nightmare_project_suffix_collision_chain() { + // "a.project" → "a_project", "a/project" → "a_project", "a_project" (natural) + let r = process_siblings_dir(&["a.project", "a/project", "a_project"]); + assert_eq!(r[0].1, "a_project"); + assert!(r[0].2); + assert_eq!(r[1].1, "a_project~1"); + assert!(r[1].2); + assert_eq!(r[2].1, "a_project~2"); + assert!(r[2].2, "natural name collides with slugified siblings"); + } + + #[test] + fn nightmare_100_identical_names() { + // 100 instances all named "Script" — dedup must handle gracefully. + let names: Vec<&str> = vec!["Script"; 100]; + let r = process_siblings_dir(&names); + assert_eq!(r[0].1, "Script"); + assert!(!r[0].2); + for (i, entry) in r.iter().enumerate().skip(1) { + assert_eq!(entry.1, format!("Script~{i}")); + assert!(entry.2); + } + // All filenames must be unique + let unique: HashSet = r.iter().map(|(_, f, _)| f.to_lowercase()).collect(); + assert_eq!(unique.len(), 100); + } + + #[test] + fn nightmare_mixed_clean_and_slug_interleaved() { + // Interleaved clean and forbidden-char names that produce same slug. + // Tests that accumulation order doesn't matter. + let r = process_siblings_dir(&[ + "A_B", // clean, claims "A_B" + "C_D", // clean, claims "C_D" + "A/B", // slug "A_B" → collision → "A_B~1" + "E_F", // clean, claims "E_F" + "C:D", // slug "C_D" → collision → "C_D~1" + "A:B", // slug "A_B" → collision with A_B and A_B~1 → "A_B~2" + ]); + assert_eq!(r[0].1, "A_B"); + assert!(!r[0].2); + assert_eq!(r[1].1, "C_D"); + assert!(!r[1].2); + assert_eq!(r[2].1, "A_B~1"); + assert!(r[2].2); + assert_eq!(r[3].1, "E_F"); + assert!(!r[3].2); + assert_eq!(r[4].1, "C_D~1"); + assert!(r[4].2); + assert_eq!(r[5].1, "A_B~2"); + assert!(r[5].2); + } + + // ══════════════════════════════════════════════════════════════════ + // IDEMPOTENCY AND INVARIANT TESTS + // + // These verify structural invariants that must always hold. + // ══════════════════════════════════════════════════════════════════ + + #[test] + fn invariant_no_two_siblings_share_filesystem_name() { + // Process a massive batch of problematic names and verify + // every resulting filename is unique (case-insensitive). + let names = [ + "Foo", + "foo", + "FOO", + "Foo", + "Foo", + "A/B", + "A:B", + "A*B", + "A_B", + "CON", + "CON_", + "con", + "Con", + "test.server", + "test_server", + "test/server", + "", + "", + "", + " X", + "X", + "X ", + "Hello\x00", + "Hello_", + "日本語", + "日本語", + ]; + let r = process_siblings_dir(&names); + let lowered: Vec = r.iter().map(|(_, f, _)| f.to_lowercase()).collect(); + let unique: HashSet<&String> = lowered.iter().collect(); + assert_eq!( + unique.len(), + lowered.len(), + "every sibling must have a unique filename. Duplicates found in: {:?}", + lowered + ); + } + + #[test] + fn invariant_every_filename_passes_validation() { + // Every filename produced by the pipeline must pass validate_file_name. + let names = [ + "CON", + "PRN", + "NUL", + "COM1", + "LPT9", + "foo.server", + "bar.meta", + "baz.model", + "A/B", + "C:D", + "E*F", + "G?H", + "", + "a|b", + "x\\y", + "q\"r", + "", + " ", + ".", + "..", + "hello.", + "hello ", + " hello", + "~1", + "~~", + "a~b~c", + ]; + let r = process_siblings_dir(&names); + for (original, filename, _) in &r { + assert!( + validate_file_name(filename).is_ok(), + "filename {filename:?} (from {original:?}) must be valid" + ); + } + } + + #[test] + fn invariant_needs_meta_iff_name_differs_from_slug() { + // For new instances: needs_meta is true IFF the filesystem name + // differs from the instance name. This is the contract. + let cases = [ + "Clean", + "Hello World", + "test-123", + "A/B", + "CON", + "foo.server", + " X", + "X ", + "", + "Foo~1", + "hello.", + ]; + for &name in &cases { + let dom = make_inst(name, "Folder"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let taken = HashSet::new(); + let (filename, needs_meta, _dk) = + name_for_inst(Middleware::Dir, child, None, &taken).unwrap(); + if needs_meta { + assert_ne!( + filename.as_ref(), + name, + "needs_meta=true but filename matches name for {name:?}" + ); + } else { + assert_eq!( + filename.as_ref(), + name, + "needs_meta=false but filename differs from name for {name:?}" + ); + } + } + } + + // ── strip_script_suffix ────────────────────────────────────────── + + #[test] + fn strip_suffix_server() { + assert_eq!(strip_script_suffix("MyScript.server"), "MyScript"); + } + + #[test] + fn strip_suffix_client() { + assert_eq!(strip_script_suffix("MyScript.client"), "MyScript"); + } + + #[test] + fn strip_suffix_plugin() { + assert_eq!(strip_script_suffix("MyScript.plugin"), "MyScript"); + } + + #[test] + fn strip_suffix_local() { + assert_eq!(strip_script_suffix("MyScript.local"), "MyScript"); + } + + #[test] + fn strip_suffix_legacy() { + assert_eq!(strip_script_suffix("MyScript.legacy"), "MyScript"); + } + + #[test] + fn strip_suffix_none() { + assert_eq!(strip_script_suffix("MyModule"), "MyModule"); + } + + #[test] + fn strip_suffix_dots_in_name() { + assert_eq!(strip_script_suffix("v1.0.server"), "v1.0"); + } + + // ── adjacent_meta_path ─────────────────────────────────────────── + + #[test] + fn adjacent_meta_server_script() { + let path = std::path::Path::new("src/Key_Script.server.luau"); + let meta = adjacent_meta_path(path); + assert_eq!(meta, std::path::PathBuf::from("src/Key_Script.meta.json5")); + } + + #[test] + fn adjacent_meta_module_script() { + let path = std::path::Path::new("src/Helper.luau"); + let meta = adjacent_meta_path(path); + assert_eq!(meta, std::path::PathBuf::from("src/Helper.meta.json5")); + } + + #[test] + fn adjacent_meta_client_script() { + let path = std::path::Path::new("src/Gui.client.luau"); + let meta = adjacent_meta_path(path); + assert_eq!(meta, std::path::PathBuf::from("src/Gui.meta.json5")); + } + + #[test] + fn adjacent_meta_legacy_lua() { + let path = std::path::Path::new("src/Old.server.lua"); + let meta = adjacent_meta_path(path); + assert_eq!(meta, std::path::PathBuf::from("src/Old.meta.json5")); + } + + // ── tilde dedup end-to-end (unit level) ────────────────────────── + + #[test] + fn tilde_dedup_collision_roundtrip() { + // Two instances "A/B" and "A_B" both slugify to "A_B". + // First gets "A_B", second gets "A_B~1". + let mut taken = HashSet::new(); + + let dom1 = make_inst("A/B", "ModuleScript"); + let child1_ref = dom1.root().children()[0]; + let child1 = dom1.get_by_ref(child1_ref).unwrap(); + let (name1, meta1, dk1) = + name_for_inst(Middleware::ModuleScript, child1, None, &taken).unwrap(); + taken.insert(dk1.to_lowercase()); + + let dom2 = make_inst("A_B", "ModuleScript"); + let child2_ref = dom2.root().children()[0]; + let child2 = dom2.get_by_ref(child2_ref).unwrap(); + let (name2, meta2, dk2) = + name_for_inst(Middleware::ModuleScript, child2, None, &taken).unwrap(); + taken.insert(dk2.to_lowercase()); + + assert_eq!(name1.as_ref(), "A_B.luau"); + assert!(meta1, "A/B was slugified, needs meta name"); + assert_eq!(name2.as_ref(), "A_B~1.luau"); + assert!(meta2, "A_B was deduped to ~1, needs meta name"); + } + + #[test] + fn tilde_in_filename_not_parsed_as_dedup_marker() { + // A file named "Foo~1" should produce instance name "Foo~1", + // NOT be interpreted as "Foo" with dedup suffix ~1. + // This is verified by the fact that name_needs_slugify("Foo~1") + // returns true (tilde is in SLUGIFY_CHARS), so a file named + // "Foo~1.luau" can only exist if it was written by the dedup + // system. Forward sync reads the filename as-is. + assert!( + name_needs_slugify("Foo~1"), + "tilde should trigger slugification" + ); + } + + // ── stress: large batch with collisions ────────────────────────── + + #[test] + fn stress_100_instances_deterministic() { + // Create 100 instances where groups of 5 share the same slug. + // Run dedup twice and verify the results are identical. + let forbidden_chars = ['/', ':', '?', '|', '<', '>', '"', '*', '\\']; + let mut names = Vec::new(); + for i in 0..100 { + let group = i / 5; + let variant = i % 5; + // Each group of 5 uses a different forbidden char so they all + // slugify to "Group_XX" but have different real names. + let ch = forbidden_chars[variant % forbidden_chars.len()]; + names.push(format!("Group{ch}{:02}", group)); + } + // Sort names alphabetically to simulate deterministic processing + names.sort(); + + let run = |names: &[String]| -> Vec<(String, bool, String)> { + let mut taken = HashSet::new(); + let mut results = Vec::new(); + for name in names { + let dom = make_inst(name, "ModuleScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + let (filename, needs_meta, dk) = + name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap(); + taken.insert(dk.to_lowercase()); + results.push((filename.into_owned(), needs_meta, dk)); + } + results + }; + + let run1 = run(&names); + let run2 = run(&names); + assert_eq!( + run1, run2, + "dedup results must be deterministic across runs" + ); + + // Verify no duplicate filenames + let filenames: HashSet = run1.iter().map(|(f, _, _)| f.to_lowercase()).collect(); + assert_eq!( + filenames.len(), + 100, + "all 100 instances should have unique filenames" + ); + } + + // ── idempotency: second pass produces same results ─────────────── + + #[test] + fn idempotency_old_inst_preserves_path() { + // When an old instance exists with a path, name_for_inst should + // return the same path regardless of taken_names. + let dom = make_inst("Hey/Bro", "ModuleScript"); + let child_ref = dom.root().children()[0]; + let child = dom.get_by_ref(child_ref).unwrap(); + + // First pass: new instance + let mut taken = HashSet::new(); + let (name1, meta1, dk1) = + name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap(); + taken.insert(dk1.to_lowercase()); + + assert_eq!(name1.as_ref(), "Hey_Bro.luau"); + assert!(meta1); + + // On the old-inst path the existing filename is preserved, + // so a second syncback (incremental) should not change the name. + // We can't easily test the full old-inst path here since it + // requires InstanceWithMeta, but we verify the dedup_key is + // stable: calling strip_middleware_extension on the result gives + // the same dedup_key. + let dk_from_filename = strip_middleware_extension(&name1, Middleware::ModuleScript); + assert_eq!(dk_from_filename.to_lowercase(), dk1.to_lowercase()); + } + + #[test] + fn syncback_forbidden_chars_produce_slugified_filenames() { + // Verify that instances with forbidden chars produce correct + // (filename, needs_meta, dedup_key) via name_for_inst + let mut dom = rbx_dom_weak::WeakDom::new(rbx_dom_weak::InstanceBuilder::new("Folder")); + let root_ref = dom.root_ref(); + let child = dom.insert( + root_ref, + rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name("Hey/Bro"), + ); + let inst = dom.get_by_ref(child).unwrap(); + let taken = std::collections::HashSet::new(); + let (filename, needs_meta, dedup_key) = name_for_inst( + crate::snapshot_middleware::Middleware::ModuleScript, + inst, + None, + &taken, + ) + .unwrap(); + assert_eq!(filename.as_ref(), "Hey_Bro.luau"); + assert!(needs_meta); + assert_eq!(dedup_key, "Hey_Bro"); + } + + #[test] + fn syncback_collision_deduplicates() { + let mut dom = rbx_dom_weak::WeakDom::new(rbx_dom_weak::InstanceBuilder::new("Folder")); + let root_ref = dom.root_ref(); + let child1 = dom.insert( + root_ref, + rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name("A/B"), + ); + let child2 = dom.insert( + root_ref, + rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name("A:B"), + ); + let inst1 = dom.get_by_ref(child1).unwrap(); + let inst2 = dom.get_by_ref(child2).unwrap(); + + let mut taken = std::collections::HashSet::new(); + let (f1, m1, dk1) = name_for_inst( + crate::snapshot_middleware::Middleware::ModuleScript, + inst1, + None, + &taken, + ) + .unwrap(); + taken.insert(dk1.to_lowercase()); + let (f2, m2, dk2) = name_for_inst( + crate::snapshot_middleware::Middleware::ModuleScript, + inst2, + None, + &taken, + ) + .unwrap(); + + assert_eq!(f1.as_ref(), "A_B.luau"); + assert!(m1); + assert_eq!(f2.as_ref(), "A_B~1.luau"); + assert!(m2); + assert_ne!(dk1.to_lowercase(), dk2.to_lowercase()); + } + + #[test] + fn syncback_idempotency_same_input_same_output() { + // Run the same set of instances through name_for_inst twice + // and verify identical output (determinism). + let names = vec!["Alpha", "Beta/Gamma", "Beta:Gamma", "Delta", "CON"]; + let mw = crate::snapshot_middleware::Middleware::ModuleScript; + + let mut dom = rbx_dom_weak::WeakDom::new(rbx_dom_weak::InstanceBuilder::new("Folder")); + let root = dom.root_ref(); + let refs: Vec<_> = names + .iter() + .map(|n| { + dom.insert( + root, + rbx_dom_weak::InstanceBuilder::new("ModuleScript").with_name(*n), + ) + }) + .collect(); + + let run = |dom: &rbx_dom_weak::WeakDom, + refs: &[rbx_dom_weak::types::Ref]| + -> Vec<(String, bool, String)> { + let mut taken = std::collections::HashSet::new(); + refs.iter() + .map(|r| { + let inst = dom.get_by_ref(*r).unwrap(); + let (f, m, dk) = name_for_inst(mw, inst, None, &taken).unwrap(); + taken.insert(dk.to_lowercase()); + (f.into_owned(), m, dk) + }) + .collect() + }; + + let result1 = run(&dom, &refs); + let result2 = run(&dom, &refs); + assert_eq!( + result1, result2, + "Two runs of name_for_inst must produce identical output" + ); + } +} diff --git a/src/syncback/meta.rs b/src/syncback/meta.rs new file mode 100644 index 00000000..20134a93 --- /dev/null +++ b/src/syncback/meta.rs @@ -0,0 +1,120 @@ +//! Shared helpers for reading and writing the `name` field in `.meta.json5` files. +//! +//! These are used by both `change_processor.rs` (two-way sync renames) and +//! `web/api.rs` (syncback added/removed instances) to keep meta file handling +//! DRY and consistent. + +use std::fs; +use std::path::Path; + +/// Upsert the `name` field in a `.meta.json5` file. +/// +/// If the file exists, parses it and merges the `name` key (preserving other +/// fields like `ignoreUnknownInstances`). If it doesn't exist, creates a new +/// file with just the `name` key. +/// +/// Returns `Ok(true)` if the file was written, `Ok(false)` if serialization +/// failed (logged), or `Err` on I/O failure. +pub fn upsert_meta_name(meta_path: &Path, real_name: &str) -> anyhow::Result<()> { + let mut obj = if meta_path.exists() { + match fs::read(meta_path) { + Ok(bytes) => match crate::json::from_slice::(&bytes) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + }, + Err(_) => serde_json::Map::new(), + } + } else { + serde_json::Map::new() + }; + 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(meta_path, &content)?; + Ok(()) +} + +/// Outcome of attempting to remove the `name` field from a meta file. +pub enum RemoveNameOutcome { + /// The meta file didn't exist or had no `name` field -- nothing changed. + NoOp, + /// The `name` field was removed; other fields remain. File was rewritten. + FieldRemoved, + /// The `name` field was the only field. The file was deleted entirely. + 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::(&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 { + 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::(&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. +/// Returns the outcome so callers can manage filesystem event suppression. +pub fn remove_meta_name(meta_path: &Path) -> anyhow::Result { + if !meta_path.exists() { + return Ok(RemoveNameOutcome::NoOp); + } + let bytes = match fs::read(meta_path) { + Ok(b) => b, + Err(_) => return Ok(RemoveNameOutcome::NoOp), + }; + let mut obj = match crate::json::from_slice::(&bytes) { + Ok(serde_json::Value::Object(map)) => map, + _ => return Ok(RemoveNameOutcome::NoOp), + }; + if obj.remove("name").is_none() { + return Ok(RemoveNameOutcome::NoOp); + } + if obj.is_empty() { + fs::remove_file(meta_path)?; + Ok(RemoveNameOutcome::FileDeleted) + } else { + let content = crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj))?; + fs::write(meta_path, &content)?; + Ok(RemoveNameOutcome::FieldRemoved) + } +} diff --git a/src/syncback/mod.rs b/src/syncback/mod.rs index 321189b0..0ca5c123 100644 --- a/src/syncback/mod.rs +++ b/src/syncback/mod.rs @@ -1,6 +1,7 @@ mod file_names; mod fs_snapshot; mod hash; +pub mod meta; mod property_filter; mod ref_properties; mod snapshot; @@ -29,7 +30,11 @@ use crate::{ Project, }; -pub use file_names::{extension_for_middleware, name_for_inst, validate_file_name}; +pub use file_names::{ + adjacent_meta_path, deduplicate_name, extension_for_middleware, name_for_inst, + name_needs_slugify, slugify_name, strip_middleware_extension, strip_script_suffix, + validate_file_name, +}; pub use fs_snapshot::FsSnapshot; pub use hash::*; pub use property_filter::{ @@ -448,6 +453,7 @@ pub fn syncback_loop_with_stats( new: new_tree.root_ref(), path: project.file_location.clone(), middleware: Some(Middleware::Project), + needs_meta_name: false, }]; let mut fs_snapshot = FsSnapshot::new(); @@ -959,7 +965,7 @@ pub fn get_best_middleware(snapshot: &SyncbackSnapshot) -> Middleware { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] +#[serde(rename_all = "camelCase")] pub struct SyncbackRules { /// A list of subtrees in a file that will be ignored by Syncback. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -996,12 +1002,6 @@ pub struct SyncbackRules { /// generally a better UX. #[serde(skip_serializing_if = "Option::is_none")] create_ignore_dir_paths: Option, - /// Whether to encode Windows-invalid characters in file names during - /// syncback. Characters like `<`, `>`, `:`, `"`, `/`, `\`, `|`, `?`, `*` - /// will be encoded as `%LT%`, `%GT%`, `%COLON%`, etc. - /// Defaults to `true`. - #[serde(skip_serializing_if = "Option::is_none")] - encode_windows_invalid_chars: Option, /// When enabled, only "visible" services will be synced back. This includes /// commonly used services like Workspace, ReplicatedStorage, ServerScriptService, /// etc., while ignoring internal/hidden services like Chat, HttpService, etc. @@ -1054,13 +1054,6 @@ impl SyncbackRules { Ok(globs) } - /// Returns whether special characters (including periods) should be encoded - /// in file names during syncback. Defaults to `true`. - #[inline] - pub fn encode_windows_invalid_chars(&self) -> bool { - self.encode_windows_invalid_chars.unwrap_or(true) - } - /// Returns whether hidden/internal services should be ignored during /// syncback. When `true`, only visible services like Workspace, /// ReplicatedStorage, etc. will be synced. Defaults to `true`. diff --git a/src/syncback/snapshot.rs b/src/syncback/snapshot.rs index 954630a9..e2cd018b 100644 --- a/src/syncback/snapshot.rs +++ b/src/syncback/snapshot.rs @@ -1,5 +1,6 @@ use indexmap::IndexMap; use memofs::Vfs; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::{ @@ -46,6 +47,10 @@ pub struct SyncbackSnapshot<'sync> { pub new: Ref, pub path: PathBuf, pub middleware: Option, + /// When `true`, the filesystem name differs from the instance name + /// (due to slugification or deduplication) and a `name` field must be + /// written in the metadata file to preserve the real instance name. + pub needs_meta_name: bool, } impl<'sync> SyncbackSnapshot<'sync> { @@ -56,7 +61,12 @@ impl<'sync> SyncbackSnapshot<'sync> { /// In clean mode (incremental=false), old_ref is ignored and set to None, /// ensuring fresh filenames and middleware choices for all children. #[inline] - pub fn with_joined_path(&self, new_ref: Ref, old_ref: Option) -> anyhow::Result { + pub fn with_joined_path( + &self, + new_ref: Ref, + old_ref: Option, + taken_names: &HashSet, + ) -> anyhow::Result<(Self, bool, String)> { // In clean mode, ignore old_ref to ensure fresh structure let effective_old_ref = if self.data.incremental { old_ref } else { None }; let mut snapshot = Self { @@ -65,18 +75,19 @@ impl<'sync> SyncbackSnapshot<'sync> { new: new_ref, path: PathBuf::new(), middleware: None, + needs_meta_name: false, }; let middleware = get_best_middleware(&snapshot); - let encode_invalid_chars = self.encode_windows_invalid_chars(); - let name = name_for_inst( + let (name, needs_meta_name, dedup_key) = name_for_inst( middleware, snapshot.new_inst(), snapshot.old_inst(), - encode_invalid_chars, + taken_names, )?; - snapshot.path = self.path.join(name.as_ref()); + snapshot.path = self.path.join(&*name); + snapshot.needs_meta_name = needs_meta_name; - Ok(snapshot) + Ok((snapshot, needs_meta_name, dedup_key)) } /// Constructs a SyncbackSnapshot from the provided refs and a base path, @@ -93,7 +104,8 @@ impl<'sync> SyncbackSnapshot<'sync> { base_path: &Path, new_ref: Ref, old_ref: Option, - ) -> anyhow::Result { + taken_names: &HashSet, + ) -> anyhow::Result<(Self, bool, String)> { // In clean mode, ignore old_ref to ensure fresh structure let effective_old_ref = if self.data.incremental { old_ref } else { None }; let mut snapshot = Self { @@ -102,18 +114,19 @@ impl<'sync> SyncbackSnapshot<'sync> { new: new_ref, path: PathBuf::new(), middleware: None, + needs_meta_name: false, }; let middleware = get_best_middleware(&snapshot); - let encode_invalid_chars = self.encode_windows_invalid_chars(); - let name = name_for_inst( + let (name, needs_meta_name, dedup_key) = name_for_inst( middleware, snapshot.new_inst(), snapshot.old_inst(), - encode_invalid_chars, + taken_names, )?; - snapshot.path = base_path.join(name.as_ref()); + snapshot.path = base_path.join(&*name); + snapshot.needs_meta_name = needs_meta_name; - Ok(snapshot) + Ok((snapshot, needs_meta_name, dedup_key)) } /// Constructs a SyncbackSnapshot with the provided path and refs while @@ -126,6 +139,7 @@ impl<'sync> SyncbackSnapshot<'sync> { new: new_ref, path, middleware: None, + needs_meta_name: false, } } @@ -319,18 +333,6 @@ impl<'sync> SyncbackSnapshot<'sync> { .unwrap_or_default() } - /// Returns whether Windows-invalid characters should be encoded in file - /// names during syncback. Defaults to `true`. - #[inline] - pub fn encode_windows_invalid_chars(&self) -> bool { - self.data - .project - .syncback_rules - .as_ref() - .map(|rules| rules.encode_windows_invalid_chars()) - .unwrap_or(true) - } - /// Returns whether to emit warnings when duplicate child names are /// encountered during syncback. Defaults to `false` (suppressed). #[inline] diff --git a/src/web/api.rs b/src/web/api.rs index b0d772f8..c29c715a 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -21,10 +21,9 @@ use rbx_dom_weak::{ }; use crate::{ - path_encoding::encode_path_name, serve_session::ServeSession, snapshot::{InstanceWithMeta, InstigatingSource, PatchSet, PatchUpdate}, - syncback::VISIBLE_SERVICES, + syncback::{slugify_name, VISIBLE_SERVICES}, web::{ interface::{ ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse, @@ -355,7 +354,7 @@ impl ApiService { // they are NOT removed from the in-memory tree. let mut actually_removed: Vec = Vec::new(); if !request.removed.is_empty() { - let removal_actions: Vec<(Ref, Option<(PathBuf, bool)>)> = { + let removal_actions: Vec<(Ref, Option)> = { let tree = self.serve_session.tree(); request .removed @@ -365,7 +364,7 @@ impl ApiService { inst.metadata().instigating_source.as_ref().and_then(|src| { match src { crate::snapshot::InstigatingSource::Path(p) => { - Some((p.clone(), p.is_dir())) + Some(p.clone()) } crate::snapshot::InstigatingSource::ProjectNode { name, @@ -387,66 +386,9 @@ impl ApiService { // 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() - ); + if let Some(path) = action { + if self.remove_instance_at_path(&path) { actually_removed.push(id); - continue; - } - if is_dir { - self.suppress_path_remove(&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()); - actually_removed.push(id); - } - } else { - self.suppress_path_remove(&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()); - actually_removed.push(id); - } - // 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_remove(&meta_path); - let _ = fs::remove_file(&meta_path); - log::info!( - "Syncback: Removed adjacent meta file at {}", - meta_path.display() - ); - } - } - } } } } @@ -464,15 +406,178 @@ impl ApiService { // 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); + // Pre-scan: identify standalone parents that need directory conversion. + // Multiple children may share the same parent. We convert each parent + // ONCE before processing any children, avoiding double-conversion that + // would wipe the parent's source (the old standalone file is deleted by + // the first conversion, so the second would read empty). + let mut converted_parents: HashMap = HashMap::new(); 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 - ); + if let Some(parent_ref) = added.parent { + if converted_parents.contains_key(&parent_ref) { + continue; + } + if let Some(parent_inst) = tree.get_instance(parent_ref) { + if let Some(source) = &parent_inst.metadata().instigating_source { + // Resolve the actual filesystem path for the parent. + // For ProjectNode sources, use the resolved $path (not + // the project file path). For init-file sources, use + // the parent directory (since init files represent + // directory-format instances that are already dirs). + let resolved_path: std::borrow::Cow<'_, Path> = match source { + crate::snapshot::InstigatingSource::Path(p) => { + let file_name = + p.file_name().and_then(|f| f.to_str()).unwrap_or(""); + if file_name.starts_with("init.") { + std::borrow::Cow::Borrowed( + p.parent().unwrap_or(p.as_path()), + ) + } else { + std::borrow::Cow::Borrowed(p.as_path()) + } + } + crate::snapshot::InstigatingSource::ProjectNode { + path: project_path, + 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 { + std::borrow::Cow::Borrowed(source.path()) + } + } + }; + if !resolved_path.is_dir() { + let parent_class = parent_inst.class_name(); + let parent_name = parent_inst.name(); + let containing_dir = match resolved_path.parent() { + Some(d) => d, + None => continue, + }; + log::info!( + "Syncback: Pre-converting standalone {} '{}' at {} to directory \ + (children being added in this batch)", + parent_class, + parent_name, + resolved_path.display() + ); + let new_dir = if matches!( + parent_class.as_str(), + "ModuleScript" | "Script" | "LocalScript" + ) { + self.convert_standalone_script_to_directory( + &resolved_path, + parent_name, + parent_class.as_str(), + containing_dir, + ) + } else { + self.convert_standalone_instance_to_directory( + &resolved_path, + parent_name, + parent_class.as_str(), + containing_dir, + ) + }; + match new_dir { + Ok(dir) => { + converted_parents.insert(parent_ref, dir); + } + Err(err) => { + log::warn!( + "Failed to pre-convert parent '{}': {}", + parent_name, + err + ); + } + } + } + } + } + } + } + + // Group added instances by parent so siblings share a dedup set. + // This prevents two instances in the same batch from claiming the + // same slug when added to the same parent. + let mut adds_by_parent: HashMap> = + HashMap::new(); + for added in request.added.values() { + if let Some(parent_ref) = added.parent { + adds_by_parent.entry(parent_ref).or_default().push(added); + } else { + // No parent — process individually (will fail with context) + adds_by_parent.entry(Ref::none()).or_default().push(added); + } + } + for (parent_ref, siblings) in &adds_by_parent { + // Pre-seed sibling_slugs from the tree's existing children + // of this parent so new instances dedup against existing ones. + // We derive slugs from actual filesystem paths (via instigating_source) + // to correctly account for dedup suffixes (e.g., Hey_Bro~1). + let mut sibling_slugs: HashSet = if *parent_ref != Ref::none() { + if let Some(parent_inst) = tree.get_instance(*parent_ref) { + use crate::snapshot::InstigatingSource; + use crate::syncback::{name_needs_slugify, strip_middleware_extension}; + parent_inst + .children() + .iter() + .filter_map(|r| tree.get_instance(*r)) + .map(|inst| { + // Prefer filesystem path for accurate dedup keys + if let Some(InstigatingSource::Path(p)) = + &inst.metadata().instigating_source + { + if let Some(fname) = p.file_name().and_then(|s| s.to_str()) { + if let Some(mw) = inst.metadata().middleware { + return strip_middleware_extension(fname, mw) + .to_lowercase(); + } + // No middleware info — use filename as-is + // (directory entries have no extension) + return fname.to_lowercase(); + } + } + // Fallback: re-slugify instance name + let name = inst.name(); + if name_needs_slugify(name) { + slugify_name(name).to_lowercase() + } else { + name.to_lowercase() + } + }) + .collect() + } else { + HashSet::new() + } + } else { + HashSet::new() + }; + for added in siblings { + if let Err(err) = self.syncback_added_instance( + added, + &tree, + &duplicate_siblings_cache, + &stats, + &converted_parents, + &mut sibling_slugs, + ) { + log::warn!( + "Failed to syncback added instance '{}': {}", + added.name, + err + ); + } } } } @@ -663,18 +768,11 @@ impl ApiService { tree: &crate::snapshot::RojoTree, duplicate_siblings_cache: &HashSet, stats: &crate::syncback::SyncbackStats, + converted_parents: &HashMap, + sibling_slugs: &mut HashSet, ) -> 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 @@ -756,9 +854,18 @@ impl ApiService { // 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() { + // + // Check converted_parents first — if another child in this batch + // already triggered the conversion, use the cached directory path + // instead of trying to convert again (the standalone file was already + // deleted by the first conversion). + let parent_dir = if let Some(dir) = converted_parents.get(&parent_ref) { + dir.clone() + } else if parent_path.is_dir() { parent_path.to_path_buf() } else { + // Fallback: parent wasn't pre-converted (e.g., parent was resolved + // through a ProjectNode path). Convert it now. let parent_class = parent_instance.class_name(); let parent_name = parent_instance.name(); let containing_dir = parent_path @@ -794,8 +901,13 @@ impl ApiService { } }; - // Create the instance at the resolved path - self.syncback_instance_to_path_with_stats(added, &parent_dir, stats) + // Create the instance at the resolved path, accumulating its claimed + // slug into sibling_slugs so subsequent siblings in the same batch + // see it as taken. + let slug = + self.syncback_instance_to_path_with_stats(added, &parent_dir, stats, sibling_slugs)?; + sibling_slugs.insert(slug); + Ok(()) } /// Update an existing instance in place instead of creating new files. @@ -951,9 +1063,7 @@ impl ApiService { // 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)?; - } + self.process_children_incremental(&unique_children, &children_dir, stats)?; } } @@ -962,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)?; + // 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, @@ -976,13 +1097,23 @@ impl ApiService { 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)?; - } + self.process_children_incremental(&unique_children, existing_path, stats)?; } } else { // It's a standalone file (e.g., .model.json5) - let content = self.serialize_instance_to_model_json(added)?; + // 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()) @@ -1257,97 +1388,129 @@ impl ApiService { 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; + /// Remove an instance from the filesystem given its path. + /// + /// Handles three cases: + /// - Directory: `remove_dir_all` + /// - Init file (`init.luau`, `init.server.luau`, etc.): remove parent directory + /// (which IS the instance) + grandparent-level adjacent meta + /// - Regular file: remove the file + adjacent `.meta.json5` + /// + /// Returns `true` if the removal succeeded (or the path was already gone), + /// `false` if the filesystem operation failed (logged as warning). + fn remove_instance_at_path(&self, path: &Path) -> bool { + use crate::syncback::adjacent_meta_path; - // 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!( + if !path.exists() { + log::info!( "Syncback: Path already removed (likely parent was deleted): {}", - instance_path.display() + path.display() ); - return Ok(()); + return true; } - // Delete the file or directory - if instance_path.is_dir() { - self.suppress_path_remove(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_remove(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()); + if path.is_dir() { + self.suppress_path_remove(path); + if let Err(err) = fs::remove_dir_all(path) { + log::warn!("Failed to remove directory {:?}: {}", path, err); + return false; + } + log::info!("Syncback: Removed directory at {}", 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() - ); + // 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; + } + + // File: check if it's an init file (directory-format script) + let file_name = path.file_name().and_then(|f| f.to_str()).unwrap_or(""); + let is_init_file = file_name.starts_with("init."); + + if is_init_file { + // Directory-format script: the parent directory represents the + // instance (e.g. src/MyModule/init.luau → the directory IS MyModule). + // Remove the entire directory. + let dir_path = path.parent().unwrap(); + self.suppress_path_remove(dir_path); + if let Err(err) = fs::remove_dir_all(dir_path) { + log::warn!( + "Failed to remove directory for init file {:?}: {}", + dir_path, + err + ); + return false; + } + log::info!( + "Syncback: Removed directory {} (init file: {})", + dir_path.display(), + path.display() + ); + + // Also remove adjacent dir-level meta in the grandparent if it + // exists (e.g. grandparent/MyModule.meta.json5). + if let Some(grandparent) = dir_path.parent() { + if let Some(dir_name) = dir_path.file_name().and_then(|f| f.to_str()) { + let dir_meta = grandparent.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 + ); + } + } + } + } + } else { + // Regular file: remove the file itself + self.suppress_path_remove(path); + if let Err(err) = fs::remove_file(path) { + log::warn!("Failed to remove file {:?}: {}", path, err); + return false; + } + log::info!("Syncback: Removed file at {}", path.display()); + + // Remove the adjacent meta file if it exists. The meta file is + // named after the script file's base stem (the slugified name), + // not the raw instance name. + let meta_path = adjacent_meta_path(path); + if meta_path.exists() { + self.suppress_path_remove(&meta_path); + 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(()) + true } /// Check for duplicate names among children and return the set of unique children @@ -1479,32 +1642,48 @@ impl ApiService { ) -> 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) + let sibling_slugs = HashSet::new(); + self.syncback_instance_to_path_with_stats(added, parent_dir, &stats, &sibling_slugs) + .map(|_| ()) } /// 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. + /// + /// `sibling_slugs` contains the slugified names of siblings already claimed + /// in the parent directory (bare slugs, lowercased). This is used by + /// `deduplicate_name` to avoid collisions. Returns the lowercased slug + /// that this instance claimed, so callers can accumulate it incrementally. 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; + sibling_slugs: &HashSet, + ) -> anyhow::Result { + use crate::syncback::{deduplicate_name, name_needs_slugify}; 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 - ) - })?; + // Slugify the instance name for filesystem safety if it contains + // forbidden characters. The real name is preserved in metadata. + let needs_slug = name_needs_slugify(&added.name); + let base_name = if needs_slug { + slugify_name(&added.name) + } else { + added.name.clone() + }; - // Encode instance name for filesystem safety (Windows-invalid chars, dots, etc.) - // This matches change_processor.rs which uses encode_path_name for renames. - let encoded_name = encode_path_name(&added.name); + // Deduplicate against sibling slugs (bare instance-name-level slugs, + // not filenames with extensions). This ensures file-format instances + // are correctly detected as collisions. + let encoded_name = deduplicate_name(&base_name, sibling_slugs); + let needs_meta_name = needs_slug || encoded_name != base_name; + let meta_name_field: Option<&str> = if needs_meta_name { + Some(&added.name) + } else { + None + }; // Build path string for stats let inst_path = format!("{}/{}", parent_dir.display(), added.name); @@ -1569,18 +1748,21 @@ impl ApiService { 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)?; + self.write_script_meta_json_if_needed(&dir_path, added, meta_name_field)?; 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)?; - } + self.process_children_incremental(&unique_children, &dir_path, stats)?; } else { let file_path = parent_dir.join(format!("{}.luau", encoded_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, &encoded_name, added)?; + self.write_adjacent_script_meta_if_needed( + parent_dir, + &encoded_name, + added, + meta_name_field, + )?; log::info!("Syncback: Updated ModuleScript at {}", file_path.display()); } } @@ -1621,11 +1803,9 @@ impl ApiService { 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)?; + self.write_script_meta_json_if_needed(&dir_path, added, meta_name_field)?; 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)?; - } + self.process_children_incremental(&unique_children, &dir_path, stats)?; } else { let file_path = parent_dir.join(format!("{}.{}.luau", encoded_name, script_suffix)); @@ -1633,7 +1813,12 @@ impl ApiService { 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, &encoded_name, added)?; + self.write_adjacent_script_meta_if_needed( + parent_dir, + &encoded_name, + added, + meta_name_field, + )?; log::info!("Syncback: Updated Script at {}", file_path.display()); } } @@ -1673,18 +1858,21 @@ impl ApiService { 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)?; + self.write_script_meta_json_if_needed(&dir_path, added, meta_name_field)?; 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)?; - } + self.process_children_incremental(&unique_children, &dir_path, stats)?; } else { let file_path = parent_dir.join(format!("{}.local.luau", encoded_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, &encoded_name, added)?; + self.write_adjacent_script_meta_if_needed( + parent_dir, + &encoded_name, + added, + meta_name_field, + )?; log::info!("Syncback: Updated LocalScript at {}", file_path.display()); } } @@ -1698,11 +1886,11 @@ impl ApiService { 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() { + // Write init.meta.json5 if has ANY properties or needs a name field + let has_metadata = if added.properties.is_empty() && meta_name_field.is_none() { false } else { - self.write_directory_meta_json(&dir_path, added)?; + self.write_directory_meta_json(&dir_path, added, meta_name_field)?; true }; @@ -1721,9 +1909,7 @@ impl ApiService { ); // Recursively process children - for child in &unique_children { - self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?; - } + self.process_children_incremental(&unique_children, &dir_path, stats)?; } // StringValue: .txt file if no children, directory with init.meta.json5 if has children @@ -1735,14 +1921,12 @@ impl ApiService { fs::create_dir_all(&dir_path).with_context(|| { format!("Failed to create directory: {}", dir_path.display()) })?; - self.write_init_meta_json(&dir_path, added)?; + self.write_init_meta_json(&dir_path, added, meta_name_field)?; 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)?; - } + self.process_children_incremental(&unique_children, &dir_path, stats)?; } else { let value = added .properties @@ -1757,6 +1941,22 @@ impl ApiService { fs::write(&file_path, value.as_bytes()).with_context(|| { format!("Failed to write file: {}", file_path.display()) })?; + // Write adjacent meta for name preservation if slugified + if let Some(real_name) = meta_name_field { + let meta = self.build_meta_object( + None, + Some(real_name), + indexmap::IndexMap::new(), + indexmap::IndexMap::new(), + ); + let meta_path = parent_dir.join(format!("{}.meta.json5", encoded_name)); + let content = crate::json::to_vec_pretty_sorted(&meta) + .context("Failed to serialize meta")?; + self.suppress_path(&meta_path); + fs::write(&meta_path, &content).with_context(|| { + format!("Failed to write meta: {}", meta_path.display()) + })?; + } log::info!("Syncback: Created StringValue at {}", file_path.display()); } } @@ -1778,19 +1978,35 @@ impl ApiService { fs::write(&init_path, content.as_bytes()).with_context(|| { format!("Failed to write file: {}", init_path.display()) })?; + // Write init.meta.json5 for className and name preservation + self.write_init_meta_json(&dir_path, added, meta_name_field)?; 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)?; - } + self.process_children_incremental(&unique_children, &dir_path, stats)?; } else { let file_path = parent_dir.join(format!("{}.csv", encoded_name)); self.suppress_path(&file_path); fs::write(&file_path, content.as_bytes()).with_context(|| { format!("Failed to write file: {}", file_path.display()) })?; + // Write adjacent meta for name preservation if slugified + if let Some(real_name) = meta_name_field { + let meta = self.build_meta_object( + None, + Some(real_name), + indexmap::IndexMap::new(), + indexmap::IndexMap::new(), + ); + let meta_path = parent_dir.join(format!("{}.meta.json5", encoded_name)); + let content = crate::json::to_vec_pretty_sorted(&meta) + .context("Failed to serialize meta")?; + self.suppress_path(&meta_path); + fs::write(&meta_path, &content).with_context(|| { + format!("Failed to write meta: {}", meta_path.display()) + })?; + } log::info!( "Syncback: Created LocalizationTable at {}", file_path.display() @@ -1827,7 +2043,7 @@ impl ApiService { })?; // Write init.meta.json5 with class and properties - self.write_init_meta_json(&dir_path, added)?; + self.write_init_meta_json(&dir_path, added, meta_name_field)?; log::info!( "Syncback: Updated {} at {}/init.meta.json5", @@ -1836,11 +2052,9 @@ impl ApiService { ); // Recursively process children - for child in &unique_children { - self.syncback_instance_to_path_with_stats(child, &dir_path, stats)?; - } + self.process_children_incremental(&unique_children, &dir_path, stats)?; } else { - let content = self.serialize_instance_to_model_json(added)?; + let content = self.serialize_instance_to_model_json(added, meta_name_field)?; // Use the detected file path if available (preserves .model.json // vs .model.json5), otherwise default to .model.json5 let file_path = match &existing_format { @@ -1860,6 +2074,86 @@ impl ApiService { } } + Ok(encoded_name.to_lowercase()) + } + + /// Extracts the bare slug from a filename by stripping Rojo's known + /// extensions (compound and simple) and script suffixes. + /// + /// Examples: + /// - `Foo.server.luau` → `Foo` (strip `.luau`, then `.server`) + /// - `Bar.client.lua` → `Bar` + /// - `Baz.model.json5` → `Baz` (compound extension) + /// - `Data.txt` → `Data` (simple extension) + /// - `init.luau` → `init` (no suffix match) + fn bare_slug_from_filename(filename: &str) -> &str { + // Try compound extensions first (must check before simple strip) + if let Some(stem) = filename + .strip_suffix(".model.json5") + .or_else(|| filename.strip_suffix(".model.json")) + .or_else(|| filename.strip_suffix(".project.json5")) + .or_else(|| filename.strip_suffix(".project.json")) + .or_else(|| filename.strip_suffix(".meta.json5")) + .or_else(|| filename.strip_suffix(".meta.json")) + { + return stem; + } + + // Strip outer extension (.luau, .lua, .csv, .txt, etc.) + let stem = std::path::Path::new(filename) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(filename); + + // Strip known script suffixes from the stem + stem.strip_suffix(".server") + .or_else(|| stem.strip_suffix(".client")) + .or_else(|| stem.strip_suffix(".plugin")) + .or_else(|| stem.strip_suffix(".local")) + .or_else(|| stem.strip_suffix(".legacy")) + .unwrap_or(stem) + } + + /// Process children with incremental slug tracking, so each child's + /// claimed name is added to the taken set before processing the next. + /// Pre-seeds the taken set from existing directory entries so new children + /// don't collide with files already on disk. + fn process_children_incremental( + &self, + children: &[&crate::web::interface::AddedInstance], + dir_path: &std::path::Path, + stats: &crate::syncback::SyncbackStats, + ) -> anyhow::Result<()> { + // Seed from existing directory entries (bare slugs, lowercased). + // For script files with compound extensions (.server.luau, .client.luau, + // etc.), we must strip both the outer extension AND the script suffix to + // recover the bare slug. Plain file_stem() only strips one extension, + // yielding "Foo.server" instead of "Foo". + let mut taken: HashSet = if dir_path.is_dir() { + fs::read_dir(dir_path) + .ok() + .map(|entries| { + entries + .flatten() + .filter_map(|e| { + let name = e.file_name(); + let name_str = name.to_string_lossy(); + if e.path().is_dir() { + Some(name_str.to_lowercase()) + } else { + Some(Self::bare_slug_from_filename(&name_str).to_lowercase()) + } + }) + .collect() + }) + .unwrap_or_default() + } else { + HashSet::new() + }; + for child in children { + let slug = self.syncback_instance_to_path_with_stats(child, dir_path, stats, &taken)?; + taken.insert(slug); + } Ok(()) } @@ -1869,6 +2163,7 @@ impl ApiService { &self, dir_path: &std::path::Path, added: &crate::web::interface::AddedInstance, + instance_name: Option<&str>, ) -> anyhow::Result<()> { use anyhow::Context; @@ -1877,11 +2172,11 @@ impl ApiService { let (properties, attributes) = self.filter_properties_for_meta(&added.class_name, &added.properties, Some("Source")); - if properties.is_empty() && attributes.is_empty() { + if properties.is_empty() && attributes.is_empty() && instance_name.is_none() { return Ok(()); } - let meta = self.build_meta_object(None, properties, attributes); + let meta = self.build_meta_object(None, instance_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")?; @@ -1903,17 +2198,18 @@ impl ApiService { &self, dir_path: &std::path::Path, added: &crate::web::interface::AddedInstance, + instance_name: Option<&str>, ) -> 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() { + if properties.is_empty() && attributes.is_empty() && instance_name.is_none() { return Ok(()); } - let meta = self.build_meta_object(None, properties, attributes); + let meta = self.build_meta_object(None, instance_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")?; @@ -1934,6 +2230,7 @@ impl ApiService { &self, dir_path: &std::path::Path, added: &crate::web::interface::AddedInstance, + instance_name: Option<&str>, ) -> anyhow::Result<()> { use anyhow::Context; @@ -1941,7 +2238,12 @@ impl ApiService { 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 = self.build_meta_object( + Some(&added.class_name), + instance_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")?; @@ -2047,6 +2349,7 @@ impl ApiService { fn build_meta_object( &self, class_name: Option<&str>, + instance_name: Option<&str>, properties: indexmap::IndexMap, attributes: indexmap::IndexMap, ) -> serde_json::Value { @@ -2058,6 +2361,10 @@ impl ApiService { obj.insert("className".to_string(), json!(cn)); } + if let Some(name) = instance_name { + obj.insert("name".to_string(), json!(name)); + } + if !properties.is_empty() { obj.insert("properties".to_string(), json!(properties)); } @@ -2134,6 +2441,7 @@ impl ApiService { parent_dir: &std::path::Path, name: &str, added: &crate::web::interface::AddedInstance, + instance_name: Option<&str>, ) -> anyhow::Result<()> { use anyhow::Context; @@ -2141,12 +2449,12 @@ impl ApiService { let (properties, attributes) = self.filter_properties_for_meta(&added.class_name, &added.properties, Some("Source")); - if properties.is_empty() && attributes.is_empty() { + if properties.is_empty() && attributes.is_empty() && instance_name.is_none() { 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 = self.build_meta_object(None, instance_name, 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")?; @@ -2166,6 +2474,7 @@ impl ApiService { fn serialize_instance_to_model_json( &self, added: &crate::web::interface::AddedInstance, + instance_name: Option<&str>, ) -> anyhow::Result> { use anyhow::Context; use serde_json::json; @@ -2177,6 +2486,9 @@ impl ApiService { // Build the JSON model structure // Format: https://rojo.space/docs/v7/sync-details/#json-models let mut model = serde_json::Map::new(); + if let Some(name) = instance_name { + model.insert("name".to_string(), json!(name)); + } model.insert("className".to_string(), json!(added.class_name)); if !properties.is_empty() { @@ -2413,7 +2725,7 @@ impl ApiService { Ok(existing) } else { // No existing file — build from scratch - Ok(self.build_meta_object(class_name, new_properties, new_attributes)) + Ok(self.build_meta_object(class_name, None, new_properties, new_attributes)) } } @@ -5387,4 +5699,821 @@ mod tests { assert_eq!(dir_name_from_instance_path(path), "Something"); } } + + // ══════════════════════════════════════════════════════════════════ + // Slug collision removal safety tests + // + // These verify the critical invariant: when an instance is removed + // and a NEW instance is created at the same slugified path, the + // removal must NOT destroy the newly created file. + // + // This was a real bug: renaming "joe_test" → "joe/test" produces + // the same slug "joe_test". The API handler deletes the old file + // and creates the new one, but the ChangeProcessor's PatchSet + // removal would re-delete the new file because path.exists() + // returned true for the NEW file. + // ══════════════════════════════════════════════════════════════════ + mod slug_collision_removal_safety { + use crate::syncback::{name_needs_slugify, slugify_name}; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::tempdir; + + /// Simulates the API handler flow: remove old file, create new file at + /// potentially the same path (because slugs collide), then verify the + /// new file survives. + fn simulate_rename_via_slug( + old_name: &str, + new_name: &str, + extension: &str, + ) -> (bool, String, String) { + // Returns: (new_file_survived, old_slug, new_slug) + let old_slug = if name_needs_slugify(old_name) { + slugify_name(old_name) + } else { + old_name.to_string() + }; + let new_slug = if name_needs_slugify(new_name) { + slugify_name(new_name) + } else { + new_name.to_string() + }; + + let temp = tempdir().expect("Failed to create temp dir"); + let old_file = temp.path().join(format!("{old_slug}.{extension}")); + let new_file = temp.path().join(format!("{new_slug}.{extension}")); + + // Step 1: Create old file (simulates existing state) + File::create(&old_file) + .unwrap() + .write_all(b"old content") + .unwrap(); + + // Step 2: API handler removes old file + if old_file.exists() { + fs::remove_file(&old_file).unwrap(); + } + + // Step 3: API handler creates new file (may be same path!) + File::create(&new_file) + .unwrap() + .write_all(b"new content") + .unwrap(); + + // Step 4: ChangeProcessor receives PatchSet with old instance removal. + // The OLD instance's instigating_source points to old_file. + // The fix: ChangeProcessor must NOT delete old_file because it + // now contains the NEW instance's content. + // + // (We don't actually call handle_tree_event here — we verify + // the invariant that the new file must survive.) + + let survived = + new_file.exists() && fs::read_to_string(&new_file).unwrap() == "new content"; + + (survived, old_slug, new_slug) + } + + // ── Same-slug renames (the critical case) ──────────────────── + + #[test] + fn rename_slash_to_underscore_same_slug() { + // "joe/test" → slug "joe_test", same as original "joe_test" + let (survived, old_slug, new_slug) = + simulate_rename_via_slug("joe_test", "joe/test", "legacy.luau"); + assert_eq!(old_slug, "joe_test"); + assert_eq!(new_slug, "joe_test"); + assert!(survived, "new file must survive when slugs collide"); + } + + #[test] + fn rename_colon_to_underscore_same_slug() { + let (survived, old, new) = simulate_rename_via_slug("A_B", "A:B", "luau"); + assert_eq!(old, "A_B"); + assert_eq!(new, "A_B"); + assert!(survived); + } + + #[test] + fn rename_star_to_underscore_same_slug() { + let (survived, old, new) = + simulate_rename_via_slug("Glob_Pattern", "Glob*Pattern", "server.luau"); + assert_eq!(old, "Glob_Pattern"); + assert_eq!(new, "Glob_Pattern"); + assert!(survived); + } + + #[test] + fn rename_question_to_underscore_same_slug() { + let (survived, _, _) = simulate_rename_via_slug("What_", "What?", "luau"); + assert!(survived); + } + + #[test] + fn rename_pipe_to_underscore_same_slug() { + let (survived, _, _) = simulate_rename_via_slug("X_Y", "X|Y", "client.luau"); + assert!(survived); + } + + #[test] + fn rename_backslash_to_underscore_same_slug() { + let (survived, _, _) = simulate_rename_via_slug("path_to", "path\\to", "luau"); + assert!(survived); + } + + #[test] + fn rename_angle_brackets_same_slug() { + let (survived, _, _) = simulate_rename_via_slug("_init_", "", "luau"); + assert!(survived); + } + + #[test] + fn rename_tilde_to_underscore_same_slug() { + let (survived, old, new) = simulate_rename_via_slug("foo_1", "foo~1", "luau"); + assert_eq!(old, "foo_1"); + assert_eq!(new, "foo_1"); + assert!(survived); + } + + // ── Different-slug renames (should also work fine) ─────────── + + #[test] + fn rename_clean_to_clean_different_slug() { + let (survived, old, new) = simulate_rename_via_slug("Alpha", "Beta", "luau"); + assert_ne!(old, new); + assert!(survived, "different slugs should trivially survive"); + } + + #[test] + fn rename_clean_to_forbidden() { + let (survived, old, new) = + simulate_rename_via_slug("MyScript", "My/Script", "server.luau"); + assert_eq!(old, "MyScript"); + assert_eq!(new, "My_Script"); + assert_ne!(old, new); + assert!(survived); + } + + #[test] + fn rename_forbidden_to_clean() { + let (survived, _, _) = simulate_rename_via_slug("X_Y", "XY", "luau"); + assert!(survived); + } + + // ── Dangerous suffix renames ───────────────────────────────── + + #[test] + fn rename_to_dangerous_suffix_server() { + let (survived, _, new) = simulate_rename_via_slug("foo", "foo.server", "luau"); + assert_eq!(new, "foo_server"); + assert!(survived); + } + + #[test] + fn rename_to_dangerous_suffix_meta() { + let (survived, _, new) = simulate_rename_via_slug("config", "config.meta", "luau"); + assert_eq!(new, "config_meta"); + assert!(survived); + } + + #[test] + fn rename_from_dangerous_suffix() { + let (survived, old, _) = simulate_rename_via_slug("foo_server", "foo.server", "luau"); + assert_eq!(old, "foo_server"); + assert!(survived); + } + + // ── Windows reserved name renames ──────────────────────────── + + #[test] + fn rename_to_con() { + let (survived, _, new) = simulate_rename_via_slug("config", "CON", "luau"); + assert_eq!(new, "CON_"); + assert!(survived); + } + + #[test] + fn rename_from_con_slug_collision() { + // "CON_" (natural) → "CON" (reserved, slugifies to "CON_") + let (survived, old, new) = simulate_rename_via_slug("CON_", "CON", "luau"); + assert_eq!(old, "CON_"); + assert_eq!(new, "CON_"); + assert!(survived, "same-slug collision from Windows reserved name"); + } + + // ── Space renames ──────────────────────────────────────────── + + #[test] + fn rename_add_leading_space() { + let (survived, old, new) = simulate_rename_via_slug("Hello", " Hello", "luau"); + assert_eq!(old, "Hello"); + assert_eq!(new, "Hello"); // leading space stripped + assert!(survived, "stripped leading space creates same slug"); + } + + #[test] + fn rename_add_trailing_space() { + let (survived, old, new) = simulate_rename_via_slug("Hello", "Hello ", "luau"); + assert_eq!(old, "Hello"); + assert_eq!(new, "Hello"); // trailing space stripped + assert!(survived); + } + + #[test] + fn rename_with_middle_space_no_collision() { + let (survived, old, new) = simulate_rename_via_slug("Hello", "Hello World", "luau"); + assert_eq!(old, "Hello"); + assert_eq!(new, "Hello World"); + assert_ne!(old, new); + assert!(survived); + } + + // ── Meta file survival ─────────────────────────────────────── + + #[test] + fn meta_file_survives_slug_collision_rename() { + let temp = tempdir().expect("Failed to create temp dir"); + let slug = "joe_test"; + + // Create old files + let old_script = temp.path().join(format!("{slug}.legacy.luau")); + let old_meta = temp.path().join(format!("{slug}.meta.json5")); + File::create(&old_script) + .unwrap() + .write_all(b"old") + .unwrap(); + File::create(&old_meta).unwrap().write_all(b"{}").unwrap(); + + // API handler: remove old + fs::remove_file(&old_script).unwrap(); + fs::remove_file(&old_meta).unwrap(); + + // API handler: create new (same slug from "joe/test") + let new_script = temp.path().join(format!("{slug}.legacy.luau")); + let new_meta = temp.path().join(format!("{slug}.meta.json5")); + File::create(&new_script) + .unwrap() + .write_all(b"new code") + .unwrap(); + File::create(&new_meta) + .unwrap() + .write_all(br#"{"name": "joe/test"}"#) + .unwrap(); + + // Both new files must survive + assert!(new_script.exists(), "new script must survive"); + assert!(new_meta.exists(), "new meta must survive"); + assert_eq!(fs::read_to_string(&new_script).unwrap(), "new code"); + assert!(fs::read_to_string(&new_meta).unwrap().contains("joe/test")); + } + + // ── Directory format survival ──────────────────────────────── + + #[test] + fn directory_survives_slug_collision_rename() { + let temp = tempdir().expect("Failed to create temp dir"); + + // Old instance "Stuff_Here" is a directory + let old_dir = temp.path().join("Stuff_Here"); + fs::create_dir(&old_dir).unwrap(); + File::create(old_dir.join("init.luau")) + .unwrap() + .write_all(b"old") + .unwrap(); + + // API handler: remove old directory + fs::remove_dir_all(&old_dir).unwrap(); + + // API handler: create new directory (slug of "Stuff/Here" = "Stuff_Here") + let new_dir = temp.path().join("Stuff_Here"); + fs::create_dir(&new_dir).unwrap(); + File::create(new_dir.join("init.luau")) + .unwrap() + .write_all(b"new") + .unwrap(); + File::create(new_dir.join("init.meta.json5")) + .unwrap() + .write_all(br#"{"name": "Stuff/Here"}"#) + .unwrap(); + + // New directory and contents must survive + assert!(new_dir.exists(), "new directory must survive"); + assert!(new_dir.join("init.luau").exists()); + assert!(new_dir.join("init.meta.json5").exists()); + assert_eq!( + fs::read_to_string(new_dir.join("init.luau")).unwrap(), + "new" + ); + } + + // ── Stress: many renames producing same slug ───────────────── + + #[test] + fn stress_many_forbidden_chars_same_slug() { + // All these names slugify to the same thing. Verify each + // rename-to scenario preserves the new file. + let variants = [ + ("A_B", "A/B"), + ("A_B", "A:B"), + ("A_B", "A*B"), + ("A_B", "A?B"), + ("A_B", "AB"), + ("A_B", "A|B"), + ("A_B", "A\\B"), + ("A_B", "A\"B"), + ("A_B", "A~B"), + ]; + for (old_name, new_name) in variants { + let (survived, old_slug, new_slug) = + simulate_rename_via_slug(old_name, new_name, "luau"); + assert_eq!( + old_slug, new_slug, + "{old_name} and {new_name} should produce same slug" + ); + assert!( + survived, + "new file must survive for {old_name} → {new_name}" + ); + } + } + + #[test] + fn stress_bidirectional_rename_survival() { + // Rename A→B then B→A, both produce same slug. + // Verify both directions work. + let pairs = [ + ("joe_test", "joe/test"), + ("Hey_Bro", "Hey:Bro"), + ("foo_1", "foo~1"), + ("CON_", "CON"), + ]; + for (a, b) in pairs { + let (surv_ab, _, _) = simulate_rename_via_slug(a, b, "luau"); + let (surv_ba, _, _) = simulate_rename_via_slug(b, a, "luau"); + assert!(surv_ab, "A→B must survive for {a} → {b}"); + assert!(surv_ba, "B→A must survive for {b} → {a}"); + } + } + + // ── Verify name_needs_slugify consistency ──────────────────── + + #[test] + fn slug_collision_only_possible_when_needs_slugify() { + // If neither old nor new needs slugifying, their slugs are + // themselves, so they can't collide (different names = different slugs). + let cases = [("Alpha", "Beta"), ("Hello", "World"), ("x", "y")]; + for (old, new) in cases { + assert!(!name_needs_slugify(old)); + assert!(!name_needs_slugify(new)); + let old_slug = slugify_name(old); + let new_slug = slugify_name(new); + assert_ne!( + old_slug, new_slug, + "clean names with different values can't produce same slug" + ); + } + } + + #[test] + fn all_forbidden_chars_produce_underscore_slug() { + // Every forbidden char in isolation slugifies to "_" → "instance" + let forbidden = ['<', '>', ':', '"', '/', '|', '?', '*', '\\', '~']; + for ch in forbidden { + let name = ch.to_string(); + assert!(name_needs_slugify(&name)); + let slug = slugify_name(&name); + assert_eq!( + slug, "instance", + "single {ch:?} should slugify to fallback 'instance'" + ); + } + } + } + + // ══════════════════════════════════════════════════════════════════ + // Two-way sync: write_child_to_disk dedup simulation + // + // These simulate the actual flow in write_child_to_disk: + // 1. Read parent directory entries → taken_names + // 2. Slugify instance name if needed + // 3. Deduplicate against taken_names + // 4. Write file + optional meta with `name` field + // + // This is the MOST critical path for two-way sync correctness. + // ══════════════════════════════════════════════════════════════════ + mod twoway_sync_dedup { + use crate::syncback::{deduplicate_name, name_needs_slugify, slugify_name}; + use std::collections::HashSet; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::tempdir; + + // ══════════════════════════════════════════════════════════════ + // IMPORTANT: deduplicate_name compares BARE SLUGS against + // taken_names. When taken_names is seeded from fs::read_dir, + // entries include extensions (e.g., "foo.luau"). The bare slug + // "foo" does NOT match "foo.luau", so dedup only catches + // collisions for DIRECTORY entries (where filename == bare name). + // + // For file-format instances, the duplicate-name pre-filter in + // the plugin (encodeInstance.lua) prevents same-name siblings + // from reaching the API at all. Cross-name slug collisions for + // file-format are a known limitation until Phase 2. + // + // These tests focus on DIRECTORY FORMAT dedup (where it works) + // and document file-format behavior accurately. + // ══════════════════════════════════════════════════════════════ + + /// Simulates the write_child_to_disk flow for DIRECTORY format: + /// directory names have no extension, so bare slug == filename, + /// and dedup against fs::read_dir entries works correctly. + fn simulate_dir_dedup( + existing_dirs: &[&str], + instance_name: &str, + ) -> (String, Option) { + let temp = tempdir().expect("Failed to create temp dir"); + for name in existing_dirs { + fs::create_dir(temp.path().join(name)).unwrap(); + } + + let taken: HashSet = fs::read_dir(temp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .map(|n| n.to_lowercase()) + .collect(); + + let needs_slug = name_needs_slugify(instance_name); + let base = if needs_slug { + slugify_name(instance_name) + } else { + instance_name.to_string() + }; + let deduped = deduplicate_name(&base, &taken); + let needs_meta = needs_slug || deduped != base; + + fs::create_dir(temp.path().join(&deduped)).unwrap(); + + let meta = if needs_meta { + Some(instance_name.to_string()) + } else { + None + }; + (deduped, meta) + } + + /// Simulates batch add of DIRECTORY format children. + fn simulate_dir_batch( + existing_dirs: &[&str], + children: &[&str], + ) -> Vec<(String, Option)> { + let temp = tempdir().expect("Failed to create temp dir"); + for name in existing_dirs { + fs::create_dir(temp.path().join(name)).unwrap(); + } + + let mut taken: HashSet = fs::read_dir(temp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .map(|n| n.to_lowercase()) + .collect(); + + let mut results = Vec::new(); + for &name in children { + let needs_slug = name_needs_slugify(name); + let base = if needs_slug { + slugify_name(name) + } else { + name.to_string() + }; + let deduped = deduplicate_name(&base, &taken); + let needs_meta = needs_slug || deduped != base; + taken.insert(deduped.to_lowercase()); + let meta = if needs_meta { + Some(name.to_string()) + } else { + None + }; + results.push((deduped, meta)); + } + results + } + + // ── Directory dedup against existing dirs ──────────────────── + + #[test] + fn dir_clean_name_no_collision() { + let (f, m) = simulate_dir_dedup(&[], "NewFolder"); + assert_eq!(f, "NewFolder"); + assert!(m.is_none()); + } + + #[test] + fn dir_clean_name_collides_with_existing() { + let (f, m) = simulate_dir_dedup(&["NewFolder"], "NewFolder"); + assert_eq!(f, "NewFolder~1"); + assert_eq!(m.unwrap(), "NewFolder"); + } + + #[test] + fn dir_forbidden_name_no_collision() { + let (f, m) = simulate_dir_dedup(&[], "Hey/Bro"); + assert_eq!(f, "Hey_Bro"); + assert_eq!(m.unwrap(), "Hey/Bro"); + } + + #[test] + fn dir_forbidden_collides_with_existing() { + let (f, m) = simulate_dir_dedup(&["Hey_Bro"], "Hey/Bro"); + assert_eq!(f, "Hey_Bro~1"); + assert_eq!(m.unwrap(), "Hey/Bro"); + } + + #[test] + fn dir_natural_collides_with_slug() { + let (f, m) = simulate_dir_dedup(&["A_B"], "A_B"); + assert_eq!(f, "A_B~1"); + assert_eq!(m.unwrap(), "A_B"); + } + + #[test] + fn dir_case_insensitive_collision() { + let (f, m) = simulate_dir_dedup(&["MyFolder"], "myfolder"); + assert_eq!(f, "myfolder~1"); + assert_eq!(m.unwrap(), "myfolder"); + } + + #[test] + fn dir_multiple_collisions() { + let (f, _) = simulate_dir_dedup(&["Foo", "Foo~1", "Foo~2"], "Foo"); + assert_eq!(f, "Foo~3"); + } + + #[test] + fn dir_gap_in_chain() { + let (f, _) = simulate_dir_dedup(&["Test", "Test~1", "Test~3"], "Test"); + assert_eq!(f, "Test~2"); + } + + #[test] + fn dir_dangerous_suffix_server() { + let (f, m) = simulate_dir_dedup(&[], "foo.server"); + assert_eq!(f, "foo_server"); + assert_eq!(m.unwrap(), "foo.server"); + } + + #[test] + fn dir_dangerous_suffix_collides() { + let (f, m) = simulate_dir_dedup(&["config_meta"], "config.meta"); + assert_eq!(f, "config_meta~1"); + assert_eq!(m.unwrap(), "config.meta"); + } + + #[test] + fn dir_con_creates_valid_name() { + let (f, m) = simulate_dir_dedup(&[], "CON"); + assert_eq!(f, "CON_"); + assert_eq!(m.unwrap(), "CON"); + } + + #[test] + fn dir_nul_collides_with_existing() { + let (f, _) = simulate_dir_dedup(&["NUL_"], "NUL"); + assert_eq!(f, "NUL_~1"); + } + + // ── Batch add: directory siblings ───────────────────────────── + + #[test] + fn batch_dir_three_clean() { + let r = simulate_dir_batch(&[], &["Alpha", "Beta", "Gamma"]); + assert_eq!(r[0].0, "Alpha"); + assert_eq!(r[1].0, "Beta"); + assert_eq!(r[2].0, "Gamma"); + assert!(r.iter().all(|(_, m)| m.is_none())); + } + + #[test] + fn batch_dir_duplicate_names() { + let r = simulate_dir_batch(&[], &["Script", "Script", "Script"]); + assert_eq!(r[0].0, "Script"); + assert!(r[0].1.is_none()); + assert_eq!(r[1].0, "Script~1"); + assert_eq!(r[1].1.as_deref(), Some("Script")); + assert_eq!(r[2].0, "Script~2"); + } + + #[test] + fn batch_dir_slug_collision_siblings() { + let r = simulate_dir_batch(&[], &["X/Y", "X:Y", "X*Y"]); + assert_eq!(r[0].0, "X_Y"); + assert_eq!(r[1].0, "X_Y~1"); + assert_eq!(r[2].0, "X_Y~2"); + } + + #[test] + fn batch_dir_with_existing_and_collisions() { + let r = simulate_dir_batch(&["Utils", "Config"], &["Utils", "Config", "NewThing"]); + assert_eq!(r[0].0, "Utils~1"); + assert_eq!(r[1].0, "Config~1"); + assert_eq!(r[2].0, "NewThing"); + assert!(r[2].1.is_none()); + } + + #[test] + fn batch_dir_dangerous_then_natural_then_slug() { + let r = simulate_dir_batch(&[], &["foo.server", "foo_server", "foo/server"]); + assert_eq!(r[0].0, "foo_server"); + assert_eq!(r[1].0, "foo_server~1"); + assert_eq!(r[2].0, "foo_server~2"); + } + + #[test] + fn batch_dir_windows_reserved_pileup() { + let r = simulate_dir_batch(&[], &["CON", "CON_", "con"]); + assert_eq!(r[0].0, "CON_"); + assert_eq!(r[0].1.as_deref(), Some("CON")); + assert_eq!(r[1].0, "CON_~1"); + assert_eq!(r[1].1.as_deref(), Some("CON_")); + assert_eq!(r[2].0, "con_~2"); + assert_eq!(r[2].1.as_deref(), Some("con")); + } + + #[test] + fn batch_dir_empty_names() { + let r = simulate_dir_batch(&[], &["", "", ""]); + assert_eq!(r[0].0, "instance"); + assert_eq!(r[1].0, "instance~1"); + assert_eq!(r[2].0, "instance~2"); + } + + #[test] + fn batch_dir_20_same_name() { + let children: Vec<&str> = vec!["Spam"; 20]; + let r = simulate_dir_batch(&[], &children); + assert_eq!(r[0].0, "Spam"); + for (i, entry) in r.iter().enumerate().skip(1) { + assert_eq!(entry.0, format!("Spam~{i}")); + } + let unique: HashSet = r.iter().map(|(f, _)| f.to_lowercase()).collect(); + assert_eq!(unique.len(), 20); + } + + // ── File-format behavior documentation ─────────────────────── + + #[test] + fn file_format_dedup_with_bare_slugs() { + // Production code now seeds taken_names from tree siblings' + // slugified instance names (bare slugs, no extensions). + // This means file-format dedup works correctly. + let taken: HashSet = ["foo".to_string()].into_iter().collect(); + // Bare slug "Foo" matches "foo" in taken + let deduped = deduplicate_name("Foo", &taken); + assert_eq!(deduped, "Foo~1", "bare slug collides with existing sibling"); + } + + #[test] + fn file_format_slugify_and_dedup_combined() { + // Slugify + dedup pipeline works end-to-end for file format + let taken: HashSet = ["hey_bro".to_string()].into_iter().collect(); + + let slug = slugify_name("Hey/Bro"); + assert_eq!(slug, "Hey_Bro"); + let deduped = deduplicate_name(&slug, &taken); + assert_eq!(deduped, "Hey_Bro~1", "slug collision correctly detected"); + } + + // ── Rename → re-add cycle (the critical slug safety test) ──── + + #[test] + fn rename_cycle_same_slug_survives() { + let temp = tempdir().expect("Failed to create temp dir"); + + File::create(temp.path().join("joe_test.legacy.luau")) + .unwrap() + .write_all(b"old code") + .unwrap(); + + // API: remove old + fs::remove_file(temp.path().join("joe_test.legacy.luau")).unwrap(); + + // API: write new (dir is now empty) + let taken: HashSet = fs::read_dir(temp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .map(|n| n.to_lowercase()) + .collect(); + assert!(taken.is_empty()); + + let slug = slugify_name("joe/test"); + assert_eq!(slug, "joe_test"); + let deduped = deduplicate_name(&slug, &taken); + assert_eq!(deduped, "joe_test"); + + let new_file = temp.path().join("joe_test.legacy.luau"); + File::create(&new_file) + .unwrap() + .write_all(b"new code") + .unwrap(); + let meta_file = temp.path().join("joe_test.meta.json5"); + File::create(&meta_file) + .unwrap() + .write_all(br#"{"name": "joe/test"}"#) + .unwrap(); + + assert!(new_file.exists()); + assert!(meta_file.exists()); + assert_eq!(fs::read_to_string(&new_file).unwrap(), "new code"); + assert!(fs::read_to_string(&meta_file).unwrap().contains("joe/test")); + } + + #[test] + fn dir_add_to_populated_no_overwrite() { + // Dir has: Helper/ and Helper.meta.json5 + // Add new "Helper" dir → must get Helper~1 + let temp = tempdir().expect("Failed to create temp dir"); + fs::create_dir(temp.path().join("Helper")).unwrap(); + File::create(temp.path().join("Helper.meta.json5")) + .unwrap() + .write_all(b"{}") + .unwrap(); + + let taken: HashSet = fs::read_dir(temp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter_map(|e| e.file_name().into_string().ok()) + .map(|n| n.to_lowercase()) + .collect(); + + let deduped = deduplicate_name("Helper", &taken); + assert_eq!(deduped, "Helper~1"); + } + + // ── Nightmare batch scenarios ──────────────────────────────── + + #[test] + fn batch_dir_10_way_slug_pileup_with_existing() { + let r = simulate_dir_batch( + &["A_B"], + &[ + "A/B", "A:B", "A*B", "A?B", "AB", "A|B", "A\\B", "A\"B", + ], + ); + assert_eq!(r[0].0, "A_B~1"); + assert_eq!(r[1].0, "A_B~2"); + assert_eq!(r[2].0, "A_B~3"); + assert_eq!(r[3].0, "A_B~4"); + assert_eq!(r[4].0, "A_B~5"); + assert_eq!(r[5].0, "A_B~6"); + assert_eq!(r[6].0, "A_B~7"); + assert_eq!(r[7].0, "A_B~8"); + assert_eq!(r[8].0, "A_B~9"); + } + + #[test] + fn batch_dir_con_prn_nul_then_slugged() { + let r = simulate_dir_batch(&[], &["CON", "PRN", "CON/", "PRN/"]); + assert_eq!(r[0].0, "CON_"); + assert_eq!(r[1].0, "PRN_"); + assert_eq!(r[2].0, "CON_~1"); // "CON/" slug "CON_" collides + assert_eq!(r[3].0, "PRN_~1"); + } + + #[test] + fn batch_dir_unicode_plus_forbidden() { + let r = simulate_dir_batch(&[], &["カフェ/Bar", "カフェ:Bar", "カフェ_Bar"]); + assert_eq!(r[0].0, "カフェ_Bar"); + assert_eq!(r[1].0, "カフェ_Bar~1"); + assert_eq!(r[2].0, "カフェ_Bar~2"); + } + + #[test] + fn batch_dir_all_unique_filenames_invariant() { + // A massive batch — every result must be unique. + let children = &[ + "Foo", + "foo", + "FOO", + "A/B", + "A:B", + "A_B", + "CON", + "CON_", + "", + "", + "test.server", + "test_server", + ]; + let r = simulate_dir_batch(&[], children); + let unique: HashSet = r.iter().map(|(f, _)| f.to_lowercase()).collect(); + assert_eq!( + unique.len(), + r.len(), + "every sibling must get a unique filename" + ); + } + } } diff --git a/tests/tests/build.rs b/tests/tests/build.rs index d44196ea..26666f00 100644 --- a/tests/tests/build.rs +++ b/tests/tests/build.rs @@ -64,6 +64,12 @@ gen_build_tests! { no_name_default_project, no_name_project, no_name_top_level_project, + tilde_no_meta, + meta_name_override, + model_json_name_override, + dedup_suffix_with_meta, + init_meta_name_override, + reserved_name_override, } fn run_build_test(test_name: &str) { diff --git a/tests/tests/syncback_roundtrip.rs b/tests/tests/syncback_roundtrip.rs index b1e50c18..31d85893 100644 --- a/tests/tests/syncback_roundtrip.rs +++ b/tests/tests/syncback_roundtrip.rs @@ -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::>(), + second_snapshot.keys().collect::>(), + ); + + 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> { + let mut result = HashMap::new(); + snapshot_dir_inner(root, root, &mut result); + result +} + +fn snapshot_dir_inner(root: &Path, current: &Path, map: &mut HashMap>) { + 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"); +} diff --git a/tests/tests/two_way_sync.rs b/tests/tests/two_way_sync.rs index abd4a568..9561381b 100644 --- a/tests/tests/two_way_sync.rs +++ b/tests/tests/two_way_sync.rs @@ -1232,28 +1232,31 @@ fn get_encoded_names_instance( } // --------------------------------------------------------------------------- -// Tests: Encoded name directory conversion (path encoding fix) +// Tests: Slugified name directory conversion (path encoding fix) // --------------------------------------------------------------------------- -/// Test 22: Adding a child to a standalone ModuleScript with Rojo-encoded -/// characters (e.g., `What%QUESTION%Module.luau`) creates the directory using -/// the filesystem-encoded name (`What%QUESTION%Module/`), NOT the decoded +/// Test 22: Adding a child to a standalone ModuleScript with slugified +/// characters (e.g., `What_Module.luau`) creates the directory using +/// the slugified filesystem name (`What_Module/`), NOT the decoded /// instance name (`What?Module/`). /// -/// This is the core regression test for the path encoding fix in +/// This is the core regression test for the slugified name fix in /// `convert_standalone_script_to_directory`. #[test] fn add_child_to_encoded_module_creates_encoded_directory() { run_serve_test("syncback_encoded_names", |session, _redactions| { let (session_id, encoded_module_id) = get_encoded_names_instance(&session, "What?Module"); - let standalone_file = session.path().join("src").join("What%QUESTION%Module.luau"); - // The CORRECT directory name uses the encoded filesystem name - let encoded_dir = session.path().join("src").join("What%QUESTION%Module"); + let standalone_file = session.path().join("src").join("What_Module.luau"); + // The CORRECT directory name uses the slugified filesystem name + let encoded_dir = session.path().join("src").join("What_Module"); // The WRONG directory name would use the decoded instance name let decoded_dir = session.path().join("src").join("What?Module"); - assert_file_exists(&standalone_file, "Encoded standalone file before child add"); + assert_file_exists( + &standalone_file, + "Slugified standalone file before child add", + ); // Add a child to trigger standalone → directory conversion let mut properties = HashMap::new(); @@ -1285,10 +1288,10 @@ fn add_child_to_encoded_module_creates_encoded_directory() { "Standalone file after directory conversion", ); - // Directory should use ENCODED name (the fix) + // Directory should use SLUGIFIED name (the fix) assert!( encoded_dir.is_dir(), - "Directory should use encoded name (What%QUESTION%Module/): {}", + "Directory should use slugified name (What_Module/): {}", encoded_dir.display() ); @@ -1299,38 +1302,35 @@ fn add_child_to_encoded_module_creates_encoded_directory() { decoded_dir.display() ); - // init.luau should exist inside the encoded directory + // init.luau should exist inside the slugified directory let init_file = encoded_dir.join("init.luau"); - assert_file_exists(&init_file, "init.luau inside encoded directory"); + assert_file_exists(&init_file, "init.luau inside slugified directory"); - // Child should be created inside the encoded directory + // Child should be created inside the slugified directory let child_file = encoded_dir.join("EncodedChild.luau"); - assert_file_exists(&child_file, "Child inside encoded directory"); + assert_file_exists(&child_file, "Child inside slugified directory"); }); } -/// Test 23: Adding a child to a standalone Script with Rojo-encoded characters -/// AND a script suffix (e.g., `Key%COLON%Script.server.luau`) strips the suffix +/// Test 23: Adding a child to a standalone Script with slugified characters +/// AND a script suffix (e.g., `Key_Script.server.luau`) strips the suffix /// correctly when creating the directory name. /// -/// The directory should be `Key%COLON%Script/`, not `Key%COLON%Script.server/` +/// The directory should be `Key_Script/`, not `Key_Script.server/` /// or `Key:Script/`. #[test] fn add_child_to_encoded_script_strips_suffix_correctly() { run_serve_test("syncback_encoded_names", |session, _redactions| { let (session_id, encoded_script_id) = get_encoded_names_instance(&session, "Key:Script"); - let standalone_file = session - .path() - .join("src") - .join("Key%COLON%Script.server.luau"); - let encoded_dir = session.path().join("src").join("Key%COLON%Script"); + let standalone_file = session.path().join("src").join("Key_Script.server.luau"); + let encoded_dir = session.path().join("src").join("Key_Script"); // Wrong: decoded name let decoded_dir = session.path().join("src").join("Key:Script"); // Wrong: suffix not stripped - let unsuffixed_dir = session.path().join("src").join("Key%COLON%Script.server"); + let unsuffixed_dir = session.path().join("src").join("Key_Script.server"); - assert_file_exists(&standalone_file, "Encoded script before child add"); + assert_file_exists(&standalone_file, "Slugified script before child add"); let mut properties = HashMap::new(); properties.insert( @@ -1359,7 +1359,7 @@ fn add_child_to_encoded_script_strips_suffix_correctly() { assert!( encoded_dir.is_dir(), - "Directory should be Key%COLON%Script/ (suffix stripped, encoded): {}", + "Directory should be Key_Script/ (suffix stripped, slugified): {}", encoded_dir.display() ); assert!( @@ -1375,16 +1375,16 @@ fn add_child_to_encoded_script_strips_suffix_correctly() { // Script → init.server.luau (preserves class) let init_file = encoded_dir.join("init.server.luau"); - assert_file_exists(&init_file, "init.server.luau inside encoded directory"); + assert_file_exists(&init_file, "init.server.luau inside slugified directory"); let child_file = encoded_dir.join("ScriptChild.luau"); - assert_file_exists(&child_file, "Child inside encoded script directory"); + assert_file_exists(&child_file, "Child inside slugified script directory"); }); } -/// Test 24: Adding a child to a non-script standalone instance with Rojo-encoded -/// characters (e.g., `What%QUESTION%Model.model.json5`) creates the directory -/// using the encoded name with the compound extension stripped. +/// Test 24: Adding a child to a non-script standalone instance with slugified +/// characters (e.g., `What_Model.model.json5`) creates the directory +/// using the slugified name with the compound extension stripped. /// /// This exercises `convert_standalone_instance_to_directory`. #[test] @@ -1392,14 +1392,11 @@ fn add_child_to_encoded_model_creates_encoded_directory() { run_serve_test("syncback_encoded_names", |session, _redactions| { let (session_id, encoded_model_id) = get_encoded_names_instance(&session, "What?Model"); - let standalone_file = session - .path() - .join("src") - .join("What%QUESTION%Model.model.json5"); - let encoded_dir = session.path().join("src").join("What%QUESTION%Model"); + let standalone_file = session.path().join("src").join("What_Model.model.json5"); + let encoded_dir = session.path().join("src").join("What_Model"); let decoded_dir = session.path().join("src").join("What?Model"); - assert_file_exists(&standalone_file, "Encoded model file before child add"); + assert_file_exists(&standalone_file, "Slugified model file before child add"); let mut properties = HashMap::new(); properties.insert( @@ -1428,7 +1425,7 @@ fn add_child_to_encoded_model_creates_encoded_directory() { assert!( encoded_dir.is_dir(), - "Directory should use encoded name (What%QUESTION%Model/): {}", + "Directory should use slugified name (What_Model/): {}", encoded_dir.display() ); assert!( @@ -1439,32 +1436,32 @@ fn add_child_to_encoded_model_creates_encoded_directory() { // init.meta.json5 should exist inside the directory (converted from .model.json5) let init_meta = encoded_dir.join("init.meta.json5"); - assert_file_exists(&init_meta, "init.meta.json5 inside encoded model directory"); + assert_file_exists( + &init_meta, + "init.meta.json5 inside slugified model directory", + ); let child_file = encoded_dir.join("ModelChild.luau"); - assert_file_exists(&child_file, "Child inside encoded model directory"); + assert_file_exists(&child_file, "Child inside slugified model directory"); }); } -/// Test 25: Non-Source property update on a standalone script with Rojo-encoded -/// name creates the adjacent meta file using the encoded filesystem name, +/// Test 25: Non-Source property update on a standalone script with slugified +/// name creates the adjacent meta file using the slugified filesystem name, /// not the decoded instance name. /// -/// e.g., `What%QUESTION%Module.meta.json5` NOT `What?Module.meta.json5` +/// e.g., `What_Module.meta.json5` NOT `What?Module.meta.json5` #[test] fn property_update_on_encoded_script_uses_encoded_meta_path() { run_serve_test("syncback_encoded_names", |session, _redactions| { let (session_id, encoded_module_id) = get_encoded_names_instance(&session, "What?Module"); - let script_file = session.path().join("src").join("What%QUESTION%Module.luau"); - let encoded_meta = session - .path() - .join("src") - .join("What%QUESTION%Module.meta.json5"); + let script_file = session.path().join("src").join("What_Module.luau"); + let encoded_meta = session.path().join("src").join("What_Module.meta.json5"); let decoded_meta = session.path().join("src").join("What?Module.meta.json5"); - assert_file_exists(&script_file, "Encoded script file before property update"); - assert_not_exists(&encoded_meta, "Meta file before property update"); + assert_file_exists(&script_file, "Slugified script file before property update"); + assert_file_exists(&encoded_meta, "Meta file exists from fixture (name field)"); let mut attrs = rbx_dom_weak::types::Attributes::new(); attrs.insert("EncodedAttr".to_string(), Variant::Float64(99.0)); @@ -1483,10 +1480,10 @@ fn property_update_on_encoded_script_uses_encoded_meta_path() { }, ); - // Meta file should use encoded name + // Meta file should use slugified name assert_file_exists( &encoded_meta, - "Meta file should use encoded name (What%QUESTION%Module.meta.json5)", + "Meta file should use slugified name (What_Module.meta.json5)", ); assert!( !decoded_meta.exists(), @@ -2453,15 +2450,12 @@ fn encoded_name_rapid_rename_chain() { wait_for_settle(); - // Final file should use encoded name: Done%QUESTION%Now.luau - let final_file = src.join("Done%QUESTION%Now.luau"); + // Final file should use slugified name: Done_Now.luau + let final_file = src.join("Done_Now.luau"); assert_file_exists(&final_file, "Final encoded file"); // Original should be gone - assert_not_exists( - &src.join("What%QUESTION%Module.luau"), - "Original encoded file", - ); + assert_not_exists(&src.join("What_Module.luau"), "Original encoded file"); }); } @@ -4151,8 +4145,8 @@ fn encoded_rename_and_source_rapid() { } wait_for_settle(); - // Final: Final:Encoded -> Final%COLON%Encoded.luau - let final_file = src.join("Final%COLON%Encoded.luau"); + // Final: Final:Encoded -> Final_Encoded.luau + let final_file = src.join("Final_Encoded.luau"); assert_file_exists(&final_file, "Final encoded file"); let content = fs::read_to_string(&final_file).unwrap(); assert!( @@ -4193,8 +4187,8 @@ fn encoded_rename_and_classchange_rapid() { } wait_for_settle(); - // Final: Enc:Final as Script -> Enc%COLON%Final.server.luau - let final_file = src.join("Enc%COLON%Final.server.luau"); + // Final: Enc:Final as Script -> Enc_Final.server.luau + let final_file = src.join("Enc_Final.server.luau"); assert_file_exists(&final_file, "Final encoded+class file"); }); } @@ -4229,8 +4223,8 @@ fn encoded_all_three_combined_rapid() { } wait_for_settle(); - // Final: End?Now as LocalScript -> End%QUESTION%Now.local.luau - let final_file = src.join("End%QUESTION%Now.local.luau"); + // Final: End?Now as LocalScript -> End_Now.local.luau + let final_file = src.join("End_Now.local.luau"); assert_file_exists(&final_file, "Final all-three encoded file"); let content = fs::read_to_string(&final_file).unwrap(); assert!( @@ -5024,3 +5018,574 @@ fn failed_directory_rename_then_successful_rename() { ); }); } + +// --------------------------------------------------------------------------- +// Tests: Adding instances with forbidden chars (audit finding coverage) +// --------------------------------------------------------------------------- + +/// Adding a new sibling instance whose name contains forbidden chars should +/// produce a slugified filename + adjacent meta with the `name` field. +#[test] +fn add_instance_with_forbidden_chars_creates_slug_and_meta() { + 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 properties = HashMap::new(); + properties.insert( + "Source".to_string(), + Variant::String("-- new forbidden".to_string()), + ); + let added = AddedInstance { + parent: Some(rs_id), + name: "Hey/Bro".to_string(), + class_name: "ModuleScript".to_string(), + properties, + children: vec![], + }; + let mut added_map = HashMap::new(); + added_map.insert(Ref::new(), added); + 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 script_path = src.join("Hey_Bro.luau"); + let meta_path = src.join("Hey_Bro.meta.json5"); + + assert_file_exists(&script_path, "Slugified script file for Hey/Bro"); + assert_file_exists(&meta_path, "Meta file for Hey/Bro with name field"); + + let meta_content = fs::read_to_string(&meta_path).unwrap(); + assert!( + meta_content.contains("\"Hey/Bro\""), + "Meta should contain the real instance name \"Hey/Bro\", got: {}", + meta_content + ); + }); +} + +/// Adding two instances whose names slugify to the same string should produce +/// dedup suffixes (~1) and both should have correct meta `name` fields. +#[test] +fn add_two_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"); + + // Add "X/Y" (slugs to "X_Y") + let mut props1 = HashMap::new(); + props1.insert( + "Source".to_string(), + Variant::String("-- first".to_string()), + ); + let added1 = AddedInstance { + parent: Some(rs_id), + name: "X/Y".to_string(), + class_name: "ModuleScript".to_string(), + properties: props1, + children: vec![], + }; + + // Add "X:Y" (also slugs to "X_Y" — collision) + let mut props2 = HashMap::new(); + props2.insert( + "Source".to_string(), + Variant::String("-- second".to_string()), + ); + let added2 = AddedInstance { + parent: Some(rs_id), + name: "X:Y".to_string(), + class_name: "ModuleScript".to_string(), + properties: props2, + children: vec![], + }; + + let mut added_map = HashMap::new(); + added_map.insert(Ref::new(), added1); + added_map.insert(Ref::new(), added2); + 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 deduped = src.join("X_Y~1.luau"); + + // One should get X_Y.luau, the other X_Y~1.luau + assert!( + base.exists() && deduped.exists(), + "Both X_Y.luau and X_Y~1.luau should exist. \ + base exists: {}, deduped exists: {}", + base.exists(), + deduped.exists() + ); + + // Both should have meta files with correct names + let base_meta = src.join("X_Y.meta.json5"); + let deduped_meta = src.join("X_Y~1.meta.json5"); + assert_file_exists(&base_meta, "Meta for base slug instance"); + assert_file_exists(&deduped_meta, "Meta for deduped ~1 instance"); + + // Read both meta files and verify they contain the correct names + let meta1 = fs::read_to_string(&base_meta).unwrap(); + let meta2 = fs::read_to_string(&deduped_meta).unwrap(); + let has_xy = meta1.contains("\"X/Y\"") || meta1.contains("\"X:Y\""); + let has_xy2 = meta2.contains("\"X/Y\"") || meta2.contains("\"X:Y\""); + assert!( + has_xy, + "Base meta should contain X/Y or X:Y, got: {}", + meta1 + ); + assert!( + has_xy2, + "Deduped meta should contain X/Y or X:Y, got: {}", + meta2 + ); + }); +} + +/// Renaming a clean-named instance to a name with forbidden chars should +/// produce a slugified filename + adjacent meta with the `name` field. +#[test] +fn rename_clean_to_slugified() { + run_serve_test("syncback_encoded_names", |session, _redactions| { + let (session_id, normal_id) = get_encoded_names_instance(&session, "Normal"); + + let src = session.path().join("src"); + let old_path = src.join("Normal.luau"); + assert_file_exists(&old_path, "Normal.luau before rename"); + + send_update( + &session, + &session_id, + InstanceUpdate { + id: normal_id, + changed_name: Some("Hey/Bro".to_string()), + changed_class_name: None, + changed_properties: UstrMap::default(), + changed_metadata: None, + }, + ); + + let new_path = src.join("Hey_Bro.luau"); + let meta_path = src.join("Hey_Bro.meta.json5"); + + poll_file_exists(&new_path, "Hey_Bro.luau after rename"); + poll_not_exists(&old_path, "Normal.luau should be gone after rename"); + + assert_file_exists(&meta_path, "Meta file for Hey/Bro with name field"); + let meta_content = fs::read_to_string(&meta_path).unwrap(); + assert!( + meta_content.contains("\"Hey/Bro\""), + "Meta should contain the real instance name \"Hey/Bro\", got: {}", + meta_content + ); + }); +} + +/// Renaming a slugified instance back to a clean name should remove the +/// 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| { + let (session_id, encoded_id) = get_encoded_names_instance(&session, "What?Module"); + + let src = session.path().join("src"); + let old_path = src.join("What_Module.luau"); + let old_meta = src.join("What_Module.meta.json5"); + assert_file_exists(&old_path, "What_Module.luau before rename"); + assert_file_exists(&old_meta, "What_Module.meta.json5 before rename"); + + send_update( + &session, + &session_id, + InstanceUpdate { + id: encoded_id, + changed_name: Some("CleanName".to_string()), + changed_class_name: None, + changed_properties: UstrMap::default(), + changed_metadata: None, + }, + ); + + let new_path = src.join("CleanName.luau"); + let new_meta = src.join("CleanName.meta.json5"); + + poll_file_exists(&new_path, "CleanName.luau after rename"); + poll_not_exists(&old_path, "What_Module.luau should be gone after rename"); + poll_not_exists( + &old_meta, + "What_Module.meta.json5 should be gone after rename", + ); + + // 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!( + 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() + ); + }); +} diff --git a/vscode-rojo b/vscode-rojo index 035c12d9..be4fea0b 160000 --- a/vscode-rojo +++ b/vscode-rojo @@ -1 +1 @@ -Subproject commit 035c12d9ed499942a1e8b024d61b99675eab232d +Subproject commit be4fea0b466c3c1754e9ca97cea79dae2bef41fa