Update deduplication logic and documentation for suffix handling

- Removed the `luau` dependency from `rokit.toml`.
- Updated deduplication logic to append `~2`, `~3`, etc. instead of `~1`, `~2` for naming collisions in various files, ensuring consistent suffix management.
- Adjusted comments and documentation across multiple files to reflect the new suffix handling rules.
- Added new test cases to validate the updated deduplication behavior, ensuring accurate naming for instances with collisions.

These changes enhance the clarity and functionality of the deduplication process, improving overall consistency in naming conventions.
This commit is contained in:
Joe 2026-02-18 01:57:17 -06:00
parent a59d9d497a
commit d836ebddc8
35 changed files with 349 additions and 281 deletions

View file

@ -92,10 +92,10 @@ pub fn deduplicate_name_with_ext(
taken_names: &HashSet<String>, taken_names: &HashSet<String>,
) -> (String, String) ) -> (String, String)
// Returns: (deduped_slug, full_fs_name) // Returns: (deduped_slug, full_fs_name)
// "Foo" + ".server.luau" with collision → ("Foo~1", "Foo~1.server.luau") // "Foo" + ".server.luau" with collision → ("Foo~2", "Foo~2.server.luau")
``` ```
**Key invariant:** Suffix goes on the stem BEFORE the compound extension: `Foo~1.server.luau`, NOT `Foo.server~1.luau`. **Key invariant:** Suffix goes on the stem BEFORE the compound extension: `Foo~2.server.luau`, NOT `Foo.server~2.luau`.
### Dedup Key Semantics ### Dedup Key Semantics
@ -134,7 +134,7 @@ When the filesystem name differs from the instance name (due to slugification or
``` ```
Instance "Hey/Bro" → Hey_Bro.luau + meta: {"name": "Hey/Bro"} Instance "Hey/Bro" → Hey_Bro.luau + meta: {"name": "Hey/Bro"}
Instance "Folder" → Folder/ (no meta needed, name == slug) Instance "Folder" → Folder/ (no meta needed, name == slug)
Instance "Folder" → Folder~1/ + meta: {"name": "Folder"} Instance "Folder" → Folder~2/ + meta: {"name": "Folder"}
``` ```
--- ---
@ -155,11 +155,11 @@ pub enum DedupCleanupAction {
### Three Cleanup Rules ### Three Cleanup Rules
1. **Gap-tolerant deletion:** Deleting `~1` from `{Foo, Foo~1, Foo~2}` leaves `{Foo, Foo~2}`. No renumbering. Gaps are tolerated. 1. **Gap-tolerant deletion:** Deleting `~2` from `{Foo, Foo~2, Foo~3}` leaves `{Foo, Foo~3}`. No renumbering. Gaps are tolerated.
2. **Base-name promotion:** Deleting `Foo` (base) from `{Foo, Foo~1, Foo~2}` promotes `Foo~1` to `Foo`. `Foo~2` stays. The lowest-numbered suffix becomes the new base. 2. **Base-name promotion:** Deleting `Foo` (base) from `{Foo, Foo~2, Foo~3}` promotes `Foo~2` to `Foo`. `Foo~3` stays. The lowest-numbered suffix becomes the new base.
3. **Group-to-1 cleanup:** Reducing the group to 1 member removes the suffix entirely. Deleting from `{Foo, Foo~1}` leaves one instance → renamed to clean `Foo`. 3. **Group-to-1 cleanup:** Reducing the group to 1 member removes the suffix entirely. Deleting from `{Foo, Foo~2}` leaves one instance → renamed to clean `Foo`.
### compute_cleanup_action ### compute_cleanup_action
@ -287,7 +287,7 @@ Ref paths use **full filesystem names** including extensions and dedup suffixes:
Folder "Foo" → segment "Foo" Folder "Foo" → segment "Foo"
ModuleScript "Foo" → segment "Foo.luau" ModuleScript "Foo" → segment "Foo.luau"
Script "Foo" (server) → segment "Foo.server.luau" Script "Foo" (server) → segment "Foo.server.luau"
Dedup'd ModuleScript → segment "Foo~1.luau" Dedup'd ModuleScript → segment "Foo~2.luau"
Init-style Script → segment "Foo" (directory) Init-style Script → segment "Foo" (directory)
``` ```
@ -395,7 +395,7 @@ Dedup renames must suppress VFS events to prevent feedback loops:
### Ref Path Updates After Dedup Rename ### Ref Path Updates After Dedup Rename
After successful dedup rename (e.g., `Foo~1` → `Foo`): After successful dedup rename (e.g., `Foo~2` → `Foo`):
1. Compute old and new ref path segments 1. Compute old and new ref path segments
2. Call `update_ref_paths_after_rename(&old_prefix, &new_prefix)` 2. Call `update_ref_paths_after_rename(&old_prefix, &new_prefix)`
3. This function uses `RefPathIndex` to find affected files 3. This function uses `RefPathIndex` to find affected files
@ -475,8 +475,8 @@ When adding instances, dedup is handled via `sibling_slugs: &mut HashSet<String>
2. **CLI syncback parity:** two-way sync produces byte-identical filesystem output to CLI syncback 2. **CLI syncback parity:** two-way sync produces byte-identical filesystem output to CLI syncback
3. **Determinism:** same input always produces same filesystem output (sorted child ordering, deterministic suffix assignment) 3. **Determinism:** same input always produces same filesystem output (sorted child ordering, deterministic suffix assignment)
4. **InstigatingSource consistency:** dedup'd paths reflected in `InstigatingSource::Path` 4. **InstigatingSource consistency:** dedup'd paths reflected in `InstigatingSource::Path`
5. **Compound extensions:** suffix before compound extension (`Foo~1.server.luau`, NOT `Foo.server~1.luau`) 5. **Compound extensions:** suffix before compound extension (`Foo~2.server.luau`, NOT `Foo.server~2.luau`)
6. **Meta pairing:** `Foo~1.server.luau` pairs with `Foo~1.meta.json5` (strip script suffix, keep dedup suffix) 6. **Meta pairing:** `Foo~2.server.luau` pairs with `Foo~2.meta.json5` (strip script suffix, keep dedup suffix)
7. **Dedup key = full FS name:** collisions checked using complete filesystem name (slug + extension), lowercased 7. **Dedup key = full FS name:** collisions checked using complete filesystem name (slug + extension), lowercased
8. **Co-removed exclusion:** batch removal skips co-removed siblings when computing cleanup actions 8. **Co-removed exclusion:** batch removal skips co-removed siblings when computing cleanup actions

View file

@ -247,7 +247,7 @@ Add metadata to file-based instances. Meta files use **camelCase keys** (no `$`
**Key differences from ProjectNode:** Meta uses `className`, `properties`, `attributes` (camelCase, no `$` prefix). Adjacent meta does NOT support `className`. **Key differences from ProjectNode:** Meta uses `className`, `properties`, `attributes` (camelCase, no `$` prefix). Adjacent meta does NOT support `className`.
**`name` field (slugify support):** When an instance name contains forbidden filesystem characters (`: / ? * < > | " \`), syncback slugifies the filename (forbidden chars → `_`) and stores the real name in the `name` field. On forward sync, `name` overrides the filename-derived instance name. On collisions (e.g. `A/B` and `A_B` both slugify to `A_B`), `~1`, `~2` suffixes are appended and meta `name` fields distinguish them. **`name` field (slugify support):** When an instance name contains forbidden filesystem characters (`: / ? * < > | " \`), syncback slugifies the filename (forbidden chars → `_`) and stores the real name in the `name` field. On forward sync, `name` overrides the filename-derived instance name. On collisions (e.g. `A/B` and `A_B` both slugify to `A_B`), `~2`, `~3` suffixes are appended and meta `name` fields distinguish them.
**`Rojo_Ref_*` attributes:** Meta files may also contain `Rojo_Ref_PropertyName` attributes written by syncback/two-way sync for Ref property linking. These are managed automatically and should not be hand-edited. **`Rojo_Ref_*` attributes:** Meta files may also contain `Rojo_Ref_PropertyName` attributes written by syncback/two-way sync for Ref property linking. These are managed automatically and should not be hand-edited.

View file

@ -74,7 +74,7 @@ pub struct InstanceContext {
Instance names with forbidden filesystem chars (`<>:"/\|?*~`) are handled by: Instance names with forbidden filesystem chars (`<>:"/\|?*~`) are handled by:
1. `slugify_name()` -- replaces forbidden chars with `_` (pure, stateless) 1. `slugify_name()` -- replaces forbidden chars with `_` (pure, stateless)
2. `deduplicate_name_with_ext()` -- dedup key = full filesystem name (slug + extension), case-insensitive; appends `~1`, `~2`, etc. on collision (stateful) 2. `deduplicate_name_with_ext()` -- dedup key = full filesystem name (slug + extension), case-insensitive; appends `~2`, `~3`, etc. on collision (stateful)
3. Metadata `name` field -- stores the real instance name in `.meta.json5`/`.model.json5` when the slug differs 3. Metadata `name` field -- stores the real instance name in `.meta.json5`/`.model.json5` when the slug differs
### 3. Commands (`src/cli/`) ### 3. Commands (`src/cli/`)

View file

@ -145,8 +145,8 @@ Manages `~N` suffix cleanup after instance deletions during two-way sync.
- `compute_cleanup_action(removed_stem, remaining_stems) -> DedupCleanupAction` - Determine what to do after a deletion - `compute_cleanup_action(removed_stem, remaining_stems) -> DedupCleanupAction` - Determine what to do after a deletion
**Cleanup rules:** **Cleanup rules:**
- **Gap-tolerant:** Deleting `~1` from `{Foo, Foo~1, Foo~2}` leaves `{Foo, Foo~2}`. No renumbering. - **Gap-tolerant:** Deleting `~2` from `{Foo, Foo~2, Foo~3}` leaves `{Foo, Foo~3}`. No renumbering.
- **Base-name promotion:** Deleting `Foo` (base) from `{Foo, Foo~1, Foo~2}` promotes `Foo~1` to `Foo`. `Foo~2` stays. - **Base-name promotion:** Deleting `Foo` (base) from `{Foo, Foo~2, Foo~3}` promotes `Foo~2` to `Foo`. `Foo~3` stays.
- **Group-to-1 cleanup:** If group shrinks to 1 member, remove its suffix entirely. - **Group-to-1 cleanup:** If group shrinks to 1 member, remove its suffix entirely.
## Syncback Rules ## Syncback Rules
@ -285,12 +285,12 @@ snapshot.with_new_path(path, new_ref, old_ref)
**Filename generation** (`file_names.rs`): **Filename generation** (`file_names.rs`):
- `slugify_name()`: forbidden chars (`<>:"/\|?*~`) → `_`. Also strips leading/trailing spaces/dots, neutralizes dangerous suffixes (`.server`, `.meta`, etc.), handles Windows reserved names (`CON`, `PRN`, etc.) - `slugify_name()`: forbidden chars (`<>:"/\|?*~`) → `_`. Also strips leading/trailing spaces/dots, neutralizes dangerous suffixes (`.server`, `.meta`, etc.), handles Windows reserved names (`CON`, `PRN`, etc.)
- `deduplicate_name_with_ext()`: Dedup key = full filesystem name (slug + extension), case-insensitive. `Folder "Foo"` (key `"foo"`) and `Script "Foo"` (key `"foo.server.luau"`) do NOT collide. Appends `~1`, `~2`, etc. on collision, with suffix inserted before compound extension: `Foo~1.server.luau` - `deduplicate_name_with_ext()`: Dedup key = full filesystem name (slug + extension), case-insensitive. `Folder "Foo"` (key `"foo"`) and `Script "Foo"` (key `"foo.server.luau"`) do NOT collide. Appends `~2`, `~3`, etc. on collision, with suffix inserted before compound extension: `Foo~2.server.luau`
- `name_for_inst()` returns `(filename, needs_meta_name, dedup_key)`. `needs_meta_name` is true when the filesystem name differs from the real instance name. - `name_for_inst()` returns `(filename, needs_meta_name, dedup_key)`. `needs_meta_name` is true when the filesystem name differs from the real instance name.
- `~` is in `SLUGIFY_CHARS` so natural tilde names (e.g., `"Foo~1"`) get slugified to `"Foo_1"`, avoiding confusion with dedup suffixes. - `~` is in `SLUGIFY_CHARS` so natural tilde names (e.g., `"Foo~1"`) get slugified to `"Foo_1"`, avoiding confusion with dedup suffixes.
- Example: `Script` named "My Script" → `My Script.server.luau` - Example: `Script` named "My Script" → `My Script.server.luau`
- Example: `Script` named "Hey/Bro" → `Hey_Bro.server.luau` + meta with `name: "Hey/Bro"` - Example: `Script` named "Hey/Bro" → `Hey_Bro.server.luau` + meta with `name: "Hey/Bro"`
- Example: Two `Folder "Data"` → `Data/` + `Data~1/` (meta: `name: "Data"`) - Example: Two `Folder "Data"` → `Data/` + `Data~2/` (meta: `name: "Data"`)
### Property Filtering ### Property Filtering

View file

@ -491,7 +491,7 @@ function DomLabel:render()
end end
end end
if depth ~= 1 then if depth > 1 then
lineGuides["Connector"] = e("Frame", { lineGuides["Connector"] = e("Frame", {
Size = UDim2.new(0, 8, 0, 2), Size = UDim2.new(0, 8, 0, 2),
Position = UDim2.new(0, 2 + (12 * props.depth), 0, 12), Position = UDim2.new(0, 2 + (12 * props.depth), 0, 12),

View file

@ -137,6 +137,11 @@ function PatchVisualizer:render()
end end
end end
-- Add ROOT as the first visible node (project summary)
if patchTree.ROOT.changeInfo then
table.insert(visibleNodes, { node = patchTree.ROOT, depth = 0 })
end
-- Collect from root children in alphabetical order -- Collect from root children in alphabetical order
local rootChildren = {} local rootChildren = {}
for _, child in patchTree.ROOT.children do for _, child in patchTree.ROOT.children do

View file

@ -79,8 +79,9 @@ function Tree.new()
local tree = { local tree = {
idToNode = {}, idToNode = {},
ROOT = { ROOT = {
id = "ROOT",
className = "DataModel", className = "DataModel",
name = "ROOT", name = "game",
children = {}, children = {},
}, },
} }
@ -209,6 +210,41 @@ function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, p
end end
end end
local function computeRootChangeInfo(tree)
local totalLinesAdded = 0
local totalLinesRemoved = 0
local totalPropChanges = 0
local hasLineData = false
tree:forEach(function(node)
if not node.changeInfo then
return
end
if node.changeInfo.linesAdded then
totalLinesAdded += node.changeInfo.linesAdded
hasLineData = true
end
if node.changeInfo.linesRemoved then
totalLinesRemoved += node.changeInfo.linesRemoved
hasLineData = true
end
if node.changeInfo.propChanges then
totalPropChanges += node.changeInfo.propChanges
end
end)
if not hasLineData and totalPropChanges == 0 then
return nil
end
return {
linesAdded = if hasLineData then totalLinesAdded else nil,
linesRemoved = if hasLineData then totalLinesRemoved else nil,
isWhitespaceOnly = false,
propChanges = if totalPropChanges > 0 then totalPropChanges else nil,
}
end
local PatchTree = {} local PatchTree = {}
-- Builds a new tree from a patch and instanceMap -- Builds a new tree from a patch and instanceMap
@ -482,6 +518,8 @@ function PatchTree.build(patch, instanceMap, changeListHeaders, gitMetadata)
end end
Timer.stop() Timer.stop()
tree.ROOT.changeInfo = computeRootChangeInfo(tree)
Timer.stop() Timer.stop()
return tree return tree
end end
@ -653,6 +691,8 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
end) end)
Timer.stop() Timer.stop()
tree.ROOT.changeInfo = computeRootChangeInfo(tree)
Timer.stop() Timer.stop()
return tree return tree
end end

View file

@ -0,0 +1,28 @@
{
"lines": 629,
"bytecode": 6261,
"bytecodeInstructionCount": 986,
"codegen": 0,
"readTime": 0.000094,
"miscTime": 0.000053,
"parseTime": 0.000251,
"compileTime": 0.000709,
"codegenTime": 0.000000,
"lowerStats": {
"totalFunctions": 0,
"skippedFunctions": 0,
"spillsToSlot": 0,
"spillsToRestore": 0,
"maxSpillSlotsUsed": 0,
"blocksPreOpt": 0,
"blocksPostOpt": 0,
"maxBlockInstructions": 0,
"regAllocErrors": 0,
"loweringErrors": 0,
"blockLinearizationStats": {
"constPropInstructionCount": 0,
"timeSeconds": 0.000000
},
"functions": []
}
}

View file

@ -3,11 +3,11 @@ source: tests/rojo_test/syncback_util.rs
expression: normalized_stdout expression: normalized_stdout
--- ---
Writing src/ChildWithDuplicates/DuplicateChild/.gitkeep Writing src/ChildWithDuplicates/DuplicateChild/.gitkeep
Writing src/ChildWithDuplicates/DuplicateChild~1/init.meta.json5 Writing src/ChildWithDuplicates/DuplicateChild~2/init.meta.json5
Writing src/ChildWithoutDuplicates/Child/.gitkeep Writing src/ChildWithoutDuplicates/Child/.gitkeep
Writing src/ChildWithDuplicates Writing src/ChildWithDuplicates
Writing src/ChildWithDuplicates/DuplicateChild Writing src/ChildWithDuplicates/DuplicateChild
Writing src/ChildWithDuplicates/DuplicateChild~1 Writing src/ChildWithDuplicates/DuplicateChild~2
Writing src/ChildWithoutDuplicates Writing src/ChildWithoutDuplicates
Writing src/ChildWithoutDuplicates/Child Writing src/ChildWithoutDuplicates/Child
Removing src/ChildWithDuplicates/.gitkeep Removing src/ChildWithDuplicates/.gitkeep

View file

@ -7,7 +7,7 @@
0, 0,
1 1
], ],
Face: "Right", Face: "Left",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10023635072", TextureContent: "rbxassetid://10023635072",

View file

@ -7,7 +7,7 @@
0, 0,
1 1
], ],
Face: "Top", Face: "Right",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10023635072", TextureContent: "rbxassetid://10023635072",

View file

@ -3,14 +3,14 @@
name: "Texture", name: "Texture",
properties: { properties: {
Color3: [ Color3: [
0.41568630933761597, 0.40000003576278687,
0, 0,
1 1
], ],
Face: "Right", Face: "Top",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10025668350", TextureContent: "rbxassetid://10023635072",
Transparency: 0.6000000238418579 Transparency: 0.6499999761581421
} }
} }

View file

@ -7,7 +7,7 @@
0, 0,
1 1
], ],
Face: "Left", Face: "Right",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10025668350", TextureContent: "rbxassetid://10025668350",

View file

@ -7,6 +7,7 @@
0, 0,
1 1
], ],
Face: "Left",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10025668350", TextureContent: "rbxassetid://10025668350",

View file

@ -7,7 +7,6 @@
0, 0,
1 1
], ],
Face: "Bottom",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10025668350", TextureContent: "rbxassetid://10025668350",

View file

@ -7,7 +7,7 @@
0, 0,
1 1
], ],
Face: "Back", Face: "Bottom",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10025668350", TextureContent: "rbxassetid://10025668350",

View file

@ -7,7 +7,7 @@
0, 0,
1 1
], ],
Face: "Bottom", Face: "Back",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10023635072", TextureContent: "rbxassetid://10023635072",

View file

@ -7,6 +7,7 @@
0, 0,
1 1
], ],
Face: "Bottom",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10023635072", TextureContent: "rbxassetid://10023635072",

View file

@ -7,7 +7,6 @@
0, 0,
1 1
], ],
Face: "Left",
StudsPerTileU: 200, StudsPerTileU: 200,
StudsPerTileV: 200, StudsPerTileV: 200,
TextureContent: "rbxassetid://10023635072", TextureContent: "rbxassetid://10023635072",

View file

@ -15,16 +15,16 @@
keypoints: [ keypoints: [
{ {
color: [ color: [
0.6666666865348816, 0.364705890417099,
0, 0.11372549086809158,
1 1
], ],
time: 0 time: 0
}, },
{ {
color: [ color: [
0.6666666865348816, 0.364705890417099,
0, 0.11372549086809158,
1 1
], ],
time: 1 time: 1

View file

@ -15,16 +15,16 @@
keypoints: [ keypoints: [
{ {
color: [ color: [
0.364705890417099, 0.6666666865348816,
0.11372549086809158, 0,
1 1
], ],
time: 0 time: 0
}, },
{ {
color: [ color: [
0.364705890417099, 0.6666666865348816,
0.11372549086809158, 0,
1 1
], ],
time: 1 time: 1

View file

@ -1,6 +1,5 @@
[tools] [tools]
selene = "Kampfkarren/selene@0.30.0" selene = "Kampfkarren/selene@0.30.0"
stylua = "JohnnyMorganz/stylua@2.3.1" stylua = "JohnnyMorganz/stylua@2.3.1"
luau = "luau-lang/luau@0.708"
luau-lsp = "JohnnyMorganz/luau-lsp@1.62.0" luau-lsp = "JohnnyMorganz/luau-lsp@1.62.0"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"

View file

@ -134,7 +134,7 @@ pub fn ref_target_path(dom: &WeakDom, target_ref: Ref) -> String {
/// - `Folder "Foo"` → `"Foo"` (directory name) /// - `Folder "Foo"` → `"Foo"` (directory name)
/// - `ModuleScript "Foo"` → `"Foo.luau"` (slug + extension) /// - `ModuleScript "Foo"` → `"Foo.luau"` (slug + extension)
/// - `Script "Foo"` (server) → `"Foo.server.luau"` /// - `Script "Foo"` (server) → `"Foo.server.luau"`
/// - Dedup'd `ModuleScript "Foo"` → `"Foo~1.luau"` /// - Dedup'd `ModuleScript "Foo"` → `"Foo~2.luau"`
/// ///
/// Filesystem names are derived from `instigating_source` paths (the actual /// Filesystem names are derived from `instigating_source` paths (the actual
/// file/dir on disk). For instances without filesystem backing (inside .rbxm /// file/dir on disk). For instances without filesystem backing (inside .rbxm

View file

@ -237,7 +237,7 @@ impl RojoTree {
/// instances (derived from `instigating_source` path), falling back to /// instances (derived from `instigating_source` path), falling back to
/// the instance name for instances without filesystem backing. /// the instance name for instances without filesystem backing.
/// ///
/// Path format: `"Workspace/Model.model.json5/Attachment~1.luau"` /// Path format: `"Workspace/Model.model.json5/Attachment~2.luau"`
/// Segments are separated by `/` (no escaping needed since filesystem /// Segments are separated by `/` (no escaping needed since filesystem
/// names can't contain `/`). /// names can't contain `/`).
pub fn get_instance_by_path(&self, path: &str) -> Option<Ref> { pub fn get_instance_by_path(&self, path: &str) -> Option<Ref> {

View file

@ -763,7 +763,7 @@ pub fn syncback_project<'sync>(
// Sort remaining children by name for deterministic dedup ordering. // Sort remaining children by name for deterministic dedup ordering.
// Without this, HashMap iteration order determines which sibling gets // Without this, HashMap iteration order determines which sibling gets
// the base slug vs ~1, causing non-deterministic output across runs. // the base slug vs ~2, causing non-deterministic output across runs.
let mut remaining_children: Vec<_> = new_child_map.drain().collect(); let mut remaining_children: Vec<_> = new_child_map.drain().collect();
remaining_children.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); remaining_children.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
for (name, new_child) in remaining_children { for (name, new_child) in remaining_children {

View file

@ -1,12 +1,12 @@
//! Dedup suffix lifecycle management. //! Dedup suffix lifecycle management.
//! //!
//! When instances have duplicate names, they receive `~N` suffixes on the //! When instances have duplicate names, they receive `~N` suffixes on the
//! filesystem (e.g., `Foo`, `Foo~1`, `Foo~2`). This module provides shared //! filesystem (e.g., `Foo`, `Foo~2`, `Foo~3`). This module provides shared
//! helpers for managing suffix cleanup when instances are deleted. //! helpers for managing suffix cleanup when instances are deleted.
//! //!
//! Rules: //! Rules:
//! - **Gap-tolerant:** deleting a suffixed instance does NOT renumber remaining //! - **Gap-tolerant:** deleting a suffixed instance does NOT renumber remaining
//! siblings. Gaps (`Foo`, `Foo~2` without `~1`) are harmless. //! siblings. Gaps (`Foo`, `Foo~3` without `~2`) are harmless.
//! - **Base-name promotion:** when the base-name holder is deleted and multiple //! - **Base-name promotion:** when the base-name holder is deleted and multiple
//! siblings remain, the lowest-numbered suffix is promoted to base name. //! siblings remain, the lowest-numbered suffix is promoted to base name.
//! - **Group-to-1 cleanup:** when a deletion reduces the dedup group to exactly //! - **Group-to-1 cleanup:** when a deletion reduces the dedup group to exactly

View file

@ -267,7 +267,7 @@ pub fn slugify_name(name: &str) -> String {
result result
} }
/// Appends ~1, ~2, etc. to the slug to avoid filesystem-level collisions. /// Appends ~2, ~3, etc. to the slug to avoid filesystem-level collisions.
/// Returns `(deduped_slug, full_fs_name)`. /// Returns `(deduped_slug, full_fs_name)`.
/// ///
/// `extension` is `None` for directory middleware, `Some("server.luau")` etc. /// `extension` is `None` for directory middleware, `Some("server.luau")` etc.
@ -294,7 +294,7 @@ pub fn deduplicate_name_with_ext(
if !taken_names.contains(&full_name.to_lowercase()) { if !taken_names.contains(&full_name.to_lowercase()) {
return (base.to_string(), full_name); return (base.to_string(), full_name);
} }
for i in 1.. { for i in 2.. {
let candidate_slug = format!("{base}~{i}"); let candidate_slug = format!("{base}~{i}");
let candidate_full = build_full(&candidate_slug); let candidate_full = build_full(&candidate_slug);
if !taken_names.contains(&candidate_full.to_lowercase()) { if !taken_names.contains(&candidate_full.to_lowercase()) {
@ -304,7 +304,7 @@ pub fn deduplicate_name_with_ext(
unreachable!() unreachable!()
} }
/// Legacy wrapper: appends ~1, ~2, etc. to avoid collisions using bare slug /// Legacy wrapper: appends ~2, ~3, etc. to avoid collisions using bare slug
/// as the collision key. Used by callers that don't yet use full filesystem /// as the collision key. Used by callers that don't yet use full filesystem
/// name dedup (e.g., change_processor.rs renames). /// name dedup (e.g., change_processor.rs renames).
pub fn deduplicate_name(base: &str, taken_names: &std::collections::HashSet<String>) -> String { pub fn deduplicate_name(base: &str, taken_names: &std::collections::HashSet<String>) -> String {
@ -312,7 +312,7 @@ pub fn deduplicate_name(base: &str, taken_names: &std::collections::HashSet<Stri
if !taken_names.contains(&base_lower) { if !taken_names.contains(&base_lower) {
return base.to_string(); return base.to_string();
} }
for i in 1.. { for i in 2.. {
let candidate = format!("{base}~{i}"); let candidate = format!("{base}~{i}");
if !taken_names.contains(&candidate.to_lowercase()) { if !taken_names.contains(&candidate.to_lowercase()) {
return candidate; return candidate;
@ -569,7 +569,7 @@ mod tests {
#[test] #[test]
fn dedup_single_collision() { fn dedup_single_collision() {
let taken: HashSet<String> = ["foo".to_string()].into_iter().collect(); let taken: HashSet<String> = ["foo".to_string()].into_iter().collect();
assert_eq!(deduplicate_name("Foo", &taken), "Foo~1"); assert_eq!(deduplicate_name("Foo", &taken), "Foo~2");
} }
#[test] #[test]
@ -589,14 +589,14 @@ mod tests {
#[test] #[test]
fn dedup_gap_in_suffixes() { fn dedup_gap_in_suffixes() {
let taken: HashSet<String> = ["foo", "foo~2"].into_iter().map(String::from).collect(); let taken: HashSet<String> = ["foo", "foo~3"].into_iter().map(String::from).collect();
assert_eq!(deduplicate_name("Foo", &taken), "Foo~1"); assert_eq!(deduplicate_name("Foo", &taken), "Foo~2");
} }
#[test] #[test]
fn dedup_natural_vs_slug_collision() { fn dedup_natural_vs_slug_collision() {
let taken: HashSet<String> = ["hey_bro".to_string()].into_iter().collect(); let taken: HashSet<String> = ["hey_bro".to_string()].into_iter().collect();
assert_eq!(deduplicate_name("Hey_Bro", &taken), "Hey_Bro~1"); assert_eq!(deduplicate_name("Hey_Bro", &taken), "Hey_Bro~2");
} }
#[test] #[test]
@ -609,16 +609,16 @@ mod tests {
fn dedup_case_insensitive() { fn dedup_case_insensitive() {
// "foo" is taken, "Foo" should collide (case-insensitive filesystem) // "foo" is taken, "Foo" should collide (case-insensitive filesystem)
let taken: HashSet<String> = ["foo".to_string()].into_iter().collect(); let taken: HashSet<String> = ["foo".to_string()].into_iter().collect();
assert_eq!(deduplicate_name("Foo", &taken), "Foo~1"); assert_eq!(deduplicate_name("Foo", &taken), "Foo~2");
assert_eq!(deduplicate_name("FOO", &taken), "FOO~1"); assert_eq!(deduplicate_name("FOO", &taken), "FOO~2");
assert_eq!(deduplicate_name("foo", &taken), "foo~1"); assert_eq!(deduplicate_name("foo", &taken), "foo~2");
} }
#[test] #[test]
fn dedup_case_only_difference() { fn dedup_case_only_difference() {
// Two instances: "MyScript" and "myscript" - second must get ~1 // Two instances: "MyScript" and "myscript" - second must get ~2
let taken: HashSet<String> = ["myscript".to_string()].into_iter().collect(); let taken: HashSet<String> = ["myscript".to_string()].into_iter().collect();
assert_eq!(deduplicate_name("MyScript", &taken), "MyScript~1"); assert_eq!(deduplicate_name("MyScript", &taken), "MyScript~2");
} }
// ── name_needs_slugify ────────────────────────────────────────────── // ── name_needs_slugify ──────────────────────────────────────────────
@ -831,7 +831,7 @@ mod tests {
let (filename, needs_meta, _dk) = let (filename, needs_meta, _dk) =
name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap(); name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap();
assert_eq!(filename.as_ref(), "Foo~1.luau"); assert_eq!(filename.as_ref(), "Foo~2.luau");
assert!(needs_meta, "deduped name differs from original"); assert!(needs_meta, "deduped name differs from original");
} }
@ -844,7 +844,7 @@ mod tests {
let (filename, needs_meta, _dk) = let (filename, needs_meta, _dk) =
name_for_inst(Middleware::Dir, child, None, &taken).unwrap(); name_for_inst(Middleware::Dir, child, None, &taken).unwrap();
assert_eq!(filename.as_ref(), "Stuff~1"); assert_eq!(filename.as_ref(), "Stuff~2");
assert!(needs_meta); assert!(needs_meta);
} }
@ -858,7 +858,7 @@ mod tests {
let (filename, needs_meta, _dk) = let (filename, needs_meta, _dk) =
name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap(); name_for_inst(Middleware::ModuleScript, child, None, &taken).unwrap();
assert_eq!(filename.as_ref(), "Hey_Bro~1.luau"); assert_eq!(filename.as_ref(), "Hey_Bro~2.luau");
assert!(needs_meta); assert!(needs_meta);
} }
@ -1145,7 +1145,7 @@ mod tests {
fn nfi_slug_collides_with_existing_dir() { fn nfi_slug_collides_with_existing_dir() {
// "Hey/Bro" slugifies to "Hey_Bro", which is already taken as a dir name // "Hey/Bro" slugifies to "Hey_Bro", which is already taken as a dir name
let (f, m) = nfi("Hey/Bro", Middleware::Dir, &["hey_bro"]); let (f, m) = nfi("Hey/Bro", Middleware::Dir, &["hey_bro"]);
assert_eq!(f, "Hey_Bro~1"); assert_eq!(f, "Hey_Bro~2");
assert!(m); assert!(m);
} }
@ -1153,15 +1153,15 @@ mod tests {
fn nfi_slug_collides_with_natural_dir() { fn nfi_slug_collides_with_natural_dir() {
// Dir "Hey_Bro" exists naturally, then "Hey:Bro" slugifies to same // Dir "Hey_Bro" exists naturally, then "Hey:Bro" slugifies to same
let (f, m) = nfi("Hey:Bro", Middleware::Dir, &["hey_bro"]); let (f, m) = nfi("Hey:Bro", Middleware::Dir, &["hey_bro"]);
assert_eq!(f, "Hey_Bro~1"); assert_eq!(f, "Hey_Bro~2");
assert!(m); assert!(m);
} }
#[test] #[test]
fn nfi_two_slugs_collide_dir() { fn nfi_two_slugs_collide_dir() {
// Both "A/B" and "A:B" slugify to "A_B". First taken, second gets ~1 // Both "A/B" and "A:B" slugify to "A_B". First taken, second gets ~2
let (f, m) = nfi("A:B", Middleware::Dir, &["a_b"]); let (f, m) = nfi("A:B", Middleware::Dir, &["a_b"]);
assert_eq!(f, "A_B~1"); assert_eq!(f, "A_B~2");
assert!(m); assert!(m);
} }
@ -1173,9 +1173,9 @@ mod tests {
#[test] #[test]
fn nfi_clean_name_dedup_still_needs_meta_dir() { fn nfi_clean_name_dedup_still_needs_meta_dir() {
// Clean name "Foo", but "foo" is already taken → dedup adds ~1 → needs meta // Clean name "Foo", but "foo" is already taken → dedup adds ~2 → needs meta
let (f, m) = nfi("Foo", Middleware::Dir, &["foo"]); let (f, m) = nfi("Foo", Middleware::Dir, &["foo"]);
assert_eq!(f, "Foo~1"); assert_eq!(f, "Foo~2");
assert!( assert!(
m, m,
"dedup suffix means filesystem name differs from instance name" "dedup suffix means filesystem name differs from instance name"
@ -1185,7 +1185,7 @@ mod tests {
#[test] #[test]
fn nfi_case_collision_dir() { fn nfi_case_collision_dir() {
let (f, m) = nfi("Assets", Middleware::Dir, &["assets"]); let (f, m) = nfi("Assets", Middleware::Dir, &["assets"]);
assert_eq!(f, "Assets~1"); assert_eq!(f, "Assets~2");
assert!(m); assert!(m);
} }
@ -1193,7 +1193,7 @@ mod tests {
fn nfi_slug_collision_file_middleware() { fn nfi_slug_collision_file_middleware() {
// For file middleware, dedup compares full filesystem name against taken. // For file middleware, dedup compares full filesystem name against taken.
let (f, m) = nfi("Hey/Bro", Middleware::ModuleScript, &["hey_bro.luau"]); let (f, m) = nfi("Hey/Bro", Middleware::ModuleScript, &["hey_bro.luau"]);
assert_eq!(f, "Hey_Bro~1.luau"); assert_eq!(f, "Hey_Bro~2.luau");
assert!(m); assert!(m);
} }
@ -1222,7 +1222,7 @@ mod tests {
let r = process_siblings_dir(&["Foo", "Foo"]); let r = process_siblings_dir(&["Foo", "Foo"]);
assert_eq!(r[0].1, "Foo"); assert_eq!(r[0].1, "Foo");
assert!(!r[0].2); assert!(!r[0].2);
assert_eq!(r[1].1, "Foo~1"); assert_eq!(r[1].1, "Foo~2");
assert!(r[1].2); assert!(r[1].2);
} }
@ -1230,8 +1230,8 @@ mod tests {
fn ordering_three_identical_names() { fn ordering_three_identical_names() {
let r = process_siblings_dir(&["X", "X", "X"]); let r = process_siblings_dir(&["X", "X", "X"]);
assert_eq!(r[0].1, "X"); assert_eq!(r[0].1, "X");
assert_eq!(r[1].1, "X~1"); assert_eq!(r[1].1, "X~2");
assert_eq!(r[2].1, "X~2"); assert_eq!(r[2].1, "X~3");
} }
#[test] #[test]
@ -1240,7 +1240,7 @@ mod tests {
let r = process_siblings_dir(&["Hey_Bro", "Hey/Bro"]); let r = process_siblings_dir(&["Hey_Bro", "Hey/Bro"]);
assert_eq!(r[0].1, "Hey_Bro"); assert_eq!(r[0].1, "Hey_Bro");
assert!(!r[0].2); assert!(!r[0].2);
assert_eq!(r[1].1, "Hey_Bro~1"); assert_eq!(r[1].1, "Hey_Bro~2");
assert!(r[1].2); assert!(r[1].2);
} }
@ -1250,7 +1250,7 @@ mod tests {
let r = process_siblings_dir(&["Hey/Bro", "Hey_Bro"]); let r = process_siblings_dir(&["Hey/Bro", "Hey_Bro"]);
assert_eq!(r[0].1, "Hey_Bro"); assert_eq!(r[0].1, "Hey_Bro");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "Hey_Bro~1"); assert_eq!(r[1].1, "Hey_Bro~2");
assert!(r[1].2, "dedup suffix → needs meta even for natural name"); assert!(r[1].2, "dedup suffix → needs meta even for natural name");
} }
@ -1259,9 +1259,9 @@ mod tests {
let r = process_siblings_dir(&["Script", "script", "SCRIPT"]); let r = process_siblings_dir(&["Script", "script", "SCRIPT"]);
assert_eq!(r[0].1, "Script"); assert_eq!(r[0].1, "Script");
assert!(!r[0].2); assert!(!r[0].2);
assert_eq!(r[1].1, "script~1"); assert_eq!(r[1].1, "script~2");
assert!(r[1].2); assert!(r[1].2);
assert_eq!(r[2].1, "SCRIPT~2"); assert_eq!(r[2].1, "SCRIPT~3");
assert!(r[2].2); assert!(r[2].2);
} }
@ -1270,8 +1270,8 @@ mod tests {
// All three slugify to "A_B" // All three slugify to "A_B"
let r = process_siblings_dir(&["A/B", "A:B", "A*B"]); let r = process_siblings_dir(&["A/B", "A:B", "A*B"]);
assert_eq!(r[0].1, "A_B"); assert_eq!(r[0].1, "A_B");
assert_eq!(r[1].1, "A_B~1"); assert_eq!(r[1].1, "A_B~2");
assert_eq!(r[2].1, "A_B~2"); assert_eq!(r[2].1, "A_B~3");
assert!(r[0].2); assert!(r[0].2);
assert!(r[1].2); assert!(r[1].2);
assert!(r[2].2); assert!(r[2].2);
@ -1293,7 +1293,7 @@ mod tests {
let r = process_siblings_dir(&["CON", "CON_"]); let r = process_siblings_dir(&["CON", "CON_"]);
assert_eq!(r[0].1, "CON_"); assert_eq!(r[0].1, "CON_");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "CON_~1"); assert_eq!(r[1].1, "CON_~2");
assert!(r[1].2); assert!(r[1].2);
} }
@ -1302,17 +1302,17 @@ mod tests {
let r = process_siblings_dir(&[ let r = process_siblings_dir(&[
"Utils", // first claim "Utils", // first claim
"utils", // case collision with Utils "utils", // case collision with Utils
"Utils", // exact collision (already taken + ~1 taken) "Utils", // exact collision (already taken + ~2 taken)
"UTILS", // case collision "UTILS", // case collision
"Uti/ls", // different slug "Uti_ls", no collision "Uti/ls", // different slug "Uti_ls", no collision
]); ]);
assert_eq!(r[0].1, "Utils"); assert_eq!(r[0].1, "Utils");
assert!(!r[0].2); assert!(!r[0].2);
assert_eq!(r[1].1, "utils~1"); assert_eq!(r[1].1, "utils~2");
assert!(r[1].2); assert!(r[1].2);
assert_eq!(r[2].1, "Utils~2"); assert_eq!(r[2].1, "Utils~3");
assert!(r[2].2); assert!(r[2].2);
assert_eq!(r[3].1, "UTILS~3"); assert_eq!(r[3].1, "UTILS~4");
assert!(r[3].2); assert!(r[3].2);
assert_eq!(r[4].1, "Uti_ls"); assert_eq!(r[4].1, "Uti_ls");
assert!(r[4].2); assert!(r[4].2);
@ -1323,20 +1323,20 @@ mod tests {
// All six slugify to "X_Y" as dirs // All six slugify to "X_Y" as dirs
let r = process_siblings_dir(&["X/Y", "X:Y", "X*Y", "X?Y", "X<Y", "X>Y"]); let r = process_siblings_dir(&["X/Y", "X:Y", "X*Y", "X?Y", "X<Y", "X>Y"]);
assert_eq!(r[0].1, "X_Y"); assert_eq!(r[0].1, "X_Y");
assert_eq!(r[1].1, "X_Y~1"); assert_eq!(r[1].1, "X_Y~2");
assert_eq!(r[2].1, "X_Y~2"); assert_eq!(r[2].1, "X_Y~3");
assert_eq!(r[3].1, "X_Y~3"); assert_eq!(r[3].1, "X_Y~4");
assert_eq!(r[4].1, "X_Y~4"); assert_eq!(r[4].1, "X_Y~5");
assert_eq!(r[5].1, "X_Y~5"); assert_eq!(r[5].1, "X_Y~6");
} }
#[test] #[test]
fn ordering_tilde_in_name_vs_dedup_suffix() { fn ordering_tilde_in_name_vs_dedup_suffix() {
// "Foo~1" (tilde slugified to "Foo_1") vs dedup suffix "Foo~1" // "Foo~1" (tilde slugified to "Foo_1") vs dedup suffix "Foo~2"
// The slugified "Foo~1" → "Foo_1" which is different from dedup "Foo~1" // The slugified "Foo~1" → "Foo_1" which is different from dedup "Foo~2"
let r = process_siblings_dir(&["Foo", "Foo", "Foo~1"]); let r = process_siblings_dir(&["Foo", "Foo", "Foo~1"]);
assert_eq!(r[0].1, "Foo"); assert_eq!(r[0].1, "Foo");
assert_eq!(r[1].1, "Foo~1"); // dedup suffix assert_eq!(r[1].1, "Foo~2"); // dedup suffix
assert_eq!(r[2].1, "Foo_1"); // tilde was slugified to _ assert_eq!(r[2].1, "Foo_1"); // tilde was slugified to _
assert!(r[2].2); assert!(r[2].2);
} }
@ -1350,9 +1350,9 @@ mod tests {
]); ]);
assert_eq!(r[0].1, "Leading"); assert_eq!(r[0].1, "Leading");
assert!(r[0].2, "leading space was stripped"); assert!(r[0].2, "leading space was stripped");
assert_eq!(r[1].1, "Leading~1"); assert_eq!(r[1].1, "Leading~2");
assert!(r[1].2); assert!(r[1].2);
assert_eq!(r[2].1, "Leading~2"); assert_eq!(r[2].1, "Leading~3");
assert!(r[2].2); assert!(r[2].2);
} }
@ -1556,9 +1556,9 @@ mod tests {
#[test] #[test]
fn disk_seed_prevents_overwrite_of_existing_dir() { fn disk_seed_prevents_overwrite_of_existing_dir() {
// "Utils" directory exists on disk. New instance "Utils" (dir) // "Utils" directory exists on disk. New instance "Utils" (dir)
// must get "Utils~1". // must get "Utils~2".
let r = process_new_children_with_disk_seed(&["utils"], &[("Utils", Middleware::Dir)]); let r = process_new_children_with_disk_seed(&["utils"], &[("Utils", Middleware::Dir)]);
assert_eq!(r[0].1, "Utils~1"); assert_eq!(r[0].1, "Utils~2");
assert!(r[0].2); assert!(r[0].2);
} }
@ -1566,16 +1566,16 @@ mod tests {
fn disk_seed_prevents_overwrite_of_existing_dir_case_insensitive() { fn disk_seed_prevents_overwrite_of_existing_dir_case_insensitive() {
// "SCRIPTS" directory on disk, new instance "Scripts" (dir) must dedup. // "SCRIPTS" directory on disk, new instance "Scripts" (dir) must dedup.
let r = process_new_children_with_disk_seed(&["scripts"], &[("Scripts", Middleware::Dir)]); let r = process_new_children_with_disk_seed(&["scripts"], &[("Scripts", Middleware::Dir)]);
assert_eq!(r[0].1, "Scripts~1"); assert_eq!(r[0].1, "Scripts~2");
assert!(r[0].2); assert!(r[0].2);
} }
#[test] #[test]
fn disk_seed_slug_collision_with_existing_dir() { fn disk_seed_slug_collision_with_existing_dir() {
// "Hey_Bro" directory on disk. New instance "Hey/Bro" slugifies // "Hey_Bro" directory on disk. New instance "Hey/Bro" slugifies
// to "Hey_Bro" — must dedup to "Hey_Bro~1". // to "Hey_Bro" — must dedup to "Hey_Bro~2".
let r = process_new_children_with_disk_seed(&["hey_bro"], &[("Hey/Bro", Middleware::Dir)]); let r = process_new_children_with_disk_seed(&["hey_bro"], &[("Hey/Bro", Middleware::Dir)]);
assert_eq!(r[0].1, "Hey_Bro~1"); assert_eq!(r[0].1, "Hey_Bro~2");
assert!(r[0].2); assert!(r[0].2);
} }
@ -1591,9 +1591,9 @@ mod tests {
("NewThing", Middleware::Dir), ("NewThing", Middleware::Dir),
], ],
); );
assert_eq!(r[0].1, "Utils~1", "Utils collides with utils/ on disk"); assert_eq!(r[0].1, "Utils~2", "Utils collides with utils/ on disk");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "Config~1", "Config collides with config/ on disk"); assert_eq!(r[1].1, "Config~2", "Config collides with config/ on disk");
assert!(r[1].2); assert!(r[1].2);
assert_eq!(r[2].1, "NewThing", "NewThing is clean"); assert_eq!(r[2].1, "NewThing", "NewThing is clean");
assert!(!r[2].2); assert!(!r[2].2);
@ -1607,7 +1607,7 @@ mod tests {
&["stuff", "stuff.meta.json5", "other.luau"], &["stuff", "stuff.meta.json5", "other.luau"],
&[("Stuff", Middleware::Dir)], &[("Stuff", Middleware::Dir)],
); );
assert_eq!(r[0].1, "Stuff~1"); assert_eq!(r[0].1, "Stuff~2");
assert!(r[0].2); assert!(r[0].2);
} }
@ -1620,7 +1620,7 @@ mod tests {
&["mymodule.luau"], &["mymodule.luau"],
&[("MyModule", Middleware::ModuleScript)], &[("MyModule", Middleware::ModuleScript)],
); );
assert_eq!(r[0].1, "MyModule~1.luau"); assert_eq!(r[0].1, "MyModule~2.luau");
assert!(r[0].2, "dedup suffix means needs meta"); assert!(r[0].2, "dedup suffix means needs meta");
} }
@ -1628,7 +1628,7 @@ mod tests {
fn disk_seed_file_middleware_sibling_slug_collision_deduped() { fn disk_seed_file_middleware_sibling_slug_collision_deduped() {
// Two file-middleware children whose names slugify to the same base. // Two file-middleware children whose names slugify to the same base.
// Since taken_names accumulates dedup_keys (bare slugs), the second // Since taken_names accumulates dedup_keys (bare slugs), the second
// child correctly detects the collision and gets ~1. // child correctly detects the collision and gets ~2.
let r = process_new_children_with_disk_seed( let r = process_new_children_with_disk_seed(
&[], &[],
&[ &[
@ -1638,7 +1638,7 @@ mod tests {
); );
assert_eq!(r[0].1, "A_B.luau"); assert_eq!(r[0].1, "A_B.luau");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "A_B~1.luau"); assert_eq!(r[1].1, "A_B~2.luau");
assert!(r[1].2); assert!(r[1].2);
} }
@ -1727,12 +1727,12 @@ mod tests {
fn project_siblings_same_slug_different_names() { fn project_siblings_same_slug_different_names() {
// Two new project children: "A/B" and "A:B" both slug to "A_B". // Two new project children: "A/B" and "A:B" both slug to "A_B".
// Before fix: both got "A_B" path (collision!). // Before fix: both got "A_B" path (collision!).
// After fix: second gets "A_B~1". // After fix: second gets "A_B~2".
let r = let r =
process_project_siblings(&[], &[("A/B", Middleware::Dir), ("A:B", Middleware::Dir)]); process_project_siblings(&[], &[("A/B", Middleware::Dir), ("A:B", Middleware::Dir)]);
assert_eq!(r[0].1, "A_B"); assert_eq!(r[0].1, "A_B");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "A_B~1"); assert_eq!(r[1].1, "A_B~2");
assert!(r[1].2); assert!(r[1].2);
} }
@ -1746,13 +1746,13 @@ mod tests {
); );
assert_eq!(r[0].1, "Hey_Bro"); assert_eq!(r[0].1, "Hey_Bro");
assert!(!r[0].2); assert!(!r[0].2);
assert_eq!(r[1].1, "Hey_Bro~1"); assert_eq!(r[1].1, "Hey_Bro~2");
assert!(r[1].2); assert!(r[1].2);
} }
#[test] #[test]
fn project_siblings_three_way_slug_collision() { fn project_siblings_three_way_slug_collision() {
// Three siblings all slug to "X_Y" — must get X_Y, X_Y~1, X_Y~2. // Three siblings all slug to "X_Y" — must get X_Y, X_Y~2, X_Y~3.
let r = process_project_siblings( let r = process_project_siblings(
&[], &[],
&[ &[
@ -1762,22 +1762,22 @@ mod tests {
], ],
); );
assert_eq!(r[0].1, "X_Y"); assert_eq!(r[0].1, "X_Y");
assert_eq!(r[1].1, "X_Y~1"); assert_eq!(r[1].1, "X_Y~2");
assert_eq!(r[2].1, "X_Y~2"); assert_eq!(r[2].1, "X_Y~3");
} }
#[test] #[test]
fn project_siblings_disk_plus_siblings_collision_dir() { fn project_siblings_disk_plus_siblings_collision_dir() {
// "Helper" directory already on disk. Two new dir siblings: "Helper" x2. // "Helper" directory already on disk. Two new dir siblings: "Helper" x2.
// First → "Helper~1" (disk has "helper"). // First → "Helper~2" (disk has "helper").
// Second → "Helper~2" (both disk and first sibling taken). // Second → "Helper~3" (both disk and first sibling taken).
let r = process_project_siblings( let r = process_project_siblings(
&["helper"], &["helper"],
&[("Helper", Middleware::Dir), ("Helper", Middleware::Dir)], &[("Helper", Middleware::Dir), ("Helper", Middleware::Dir)],
); );
assert_eq!(r[0].1, "Helper~1"); assert_eq!(r[0].1, "Helper~2");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "Helper~2"); assert_eq!(r[1].1, "Helper~3");
assert!(r[1].2); assert!(r[1].2);
} }
@ -1795,11 +1795,11 @@ mod tests {
); );
assert_eq!(r[0].1, "Test"); assert_eq!(r[0].1, "Test");
assert!(!r[0].2); assert!(!r[0].2);
assert_eq!(r[1].1, "test~1"); assert_eq!(r[1].1, "test~2");
assert!(r[1].2); assert!(r[1].2);
assert_eq!(r[2].1, "TEST~2"); assert_eq!(r[2].1, "TEST~3");
assert!(r[2].2); assert!(r[2].2);
assert_eq!(r[3].1, "tEsT~3"); assert_eq!(r[3].1, "tEsT~4");
assert!(r[3].2); assert!(r[3].2);
} }
@ -1819,7 +1819,7 @@ mod tests {
let r = process_siblings_dir(&["CON", "CON/"]); let r = process_siblings_dir(&["CON", "CON/"]);
assert_eq!(r[0].1, "CON_"); assert_eq!(r[0].1, "CON_");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "CON_~1"); assert_eq!(r[1].1, "CON_~2");
assert!(r[1].2); assert!(r[1].2);
} }
@ -1829,29 +1829,29 @@ mod tests {
let r = process_siblings_dir(&["foo.server", "foo/server"]); let r = process_siblings_dir(&["foo.server", "foo/server"]);
assert_eq!(r[0].1, "foo_server"); assert_eq!(r[0].1, "foo_server");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "foo_server~1"); assert_eq!(r[1].1, "foo_server~2");
assert!(r[1].2); assert!(r[1].2);
} }
#[test] #[test]
fn nightmare_tilde_looks_like_dedup_but_gets_slugified() { fn nightmare_tilde_looks_like_dedup_but_gets_slugified() {
// "Foo~1" has tilde slugified to "Foo_1". Then real "Foo" gets // "Foo~1" has tilde slugified to "Foo_1". Then real "Foo" gets
// dedup "Foo~1". These should NOT collide because tilde-in-name // dedup "Foo~2". These should NOT collide because tilde-in-name
// becomes underscore, but dedup adds actual tilde. // becomes underscore, but dedup adds actual tilde.
let r = process_siblings_dir(&["Foo~1", "Foo", "Foo"]); let r = process_siblings_dir(&["Foo~1", "Foo", "Foo"]);
assert_eq!(r[0].1, "Foo_1", "tilde slugified"); assert_eq!(r[0].1, "Foo_1", "tilde slugified");
assert_eq!(r[1].1, "Foo", "clean, no collision"); assert_eq!(r[1].1, "Foo", "clean, no collision");
assert_eq!(r[2].1, "Foo~1", "dedup suffix, different from Foo_1"); assert_eq!(r[2].1, "Foo~2", "dedup suffix, different from Foo_1");
} }
#[test] #[test]
fn nightmare_dedup_suffix_avoids_slugified_tilde_name() { fn nightmare_dedup_suffix_avoids_slugified_tilde_name() {
// Process "Foo", then "Foo" again (dedup to "Foo~1"), then "Foo~1" // Process "Foo", then "Foo" again (dedup to "Foo~2"), then "Foo~1"
// (tilde slugified to "Foo_1"). The dedup "Foo~1" and slug "Foo_1" // (tilde slugified to "Foo_1"). The dedup "Foo~2" and slug "Foo_1"
// should coexist. // should coexist.
let r = process_siblings_dir(&["Foo", "Foo", "Foo~1"]); let r = process_siblings_dir(&["Foo", "Foo", "Foo~1"]);
assert_eq!(r[0].1, "Foo"); assert_eq!(r[0].1, "Foo");
assert_eq!(r[1].1, "Foo~1"); assert_eq!(r[1].1, "Foo~2");
assert_eq!(r[2].1, "Foo_1"); assert_eq!(r[2].1, "Foo_1");
// All three have distinct lowercased names // All three have distinct lowercased names
let names: HashSet<String> = r.iter().map(|(_, f, _)| f.to_lowercase()).collect(); let names: HashSet<String> = r.iter().map(|(_, f, _)| f.to_lowercase()).collect();
@ -1863,7 +1863,7 @@ mod tests {
// "カフェ/Bar" → "カフェ_Bar", "カフェ:Bar" → "カフェ_Bar" — collision // "カフェ/Bar" → "カフェ_Bar", "カフェ:Bar" → "カフェ_Bar" — collision
let r = process_siblings_dir(&["カフェ/Bar", "カフェ:Bar"]); let r = process_siblings_dir(&["カフェ/Bar", "カフェ:Bar"]);
assert_eq!(r[0].1, "カフェ_Bar"); assert_eq!(r[0].1, "カフェ_Bar");
assert_eq!(r[1].1, "カフェ_Bar~1"); assert_eq!(r[1].1, "カフェ_Bar~2");
} }
#[test] #[test]
@ -1872,7 +1872,7 @@ mod tests {
let r = process_siblings_dir(&["🎮 Games", "🎮 Games"]); let r = process_siblings_dir(&["🎮 Games", "🎮 Games"]);
assert_eq!(r[0].1, "🎮 Games"); assert_eq!(r[0].1, "🎮 Games");
assert!(!r[0].2, "emoji name is clean"); assert!(!r[0].2, "emoji name is clean");
assert_eq!(r[1].1, "🎮 Games~1"); assert_eq!(r[1].1, "🎮 Games~2");
assert!(r[1].2); assert!(r[1].2);
} }
@ -1883,9 +1883,9 @@ mod tests {
let r = process_siblings_dir(&["CON", "CON_", "con"]); let r = process_siblings_dir(&["CON", "CON_", "con"]);
assert_eq!(r[0].1, "CON_"); assert_eq!(r[0].1, "CON_");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "CON_~1"); assert_eq!(r[1].1, "CON_~2");
assert!(r[1].2, "natural CON_ collides with reserved CON's slug"); assert!(r[1].2, "natural CON_ collides with reserved CON's slug");
assert_eq!(r[2].1, "con_~2"); assert_eq!(r[2].1, "con_~3");
assert!(r[2].2); assert!(r[2].2);
} }
@ -1895,7 +1895,7 @@ mod tests {
let r = process_siblings_dir(&["test.", "test"]); let r = process_siblings_dir(&["test.", "test"]);
assert_eq!(r[0].1, "test"); assert_eq!(r[0].1, "test");
assert!(r[0].2, "trailing dot stripped → slug differs"); assert!(r[0].2, "trailing dot stripped → slug differs");
assert_eq!(r[1].1, "test~1"); assert_eq!(r[1].1, "test~2");
assert!(r[1].2); assert!(r[1].2);
} }
@ -1905,9 +1905,9 @@ mod tests {
let r = process_siblings_dir(&[" A", "A", " A"]); let r = process_siblings_dir(&[" A", "A", " A"]);
assert_eq!(r[0].1, "A"); assert_eq!(r[0].1, "A");
assert!(r[0].2, "leading space stripped"); assert!(r[0].2, "leading space stripped");
assert_eq!(r[1].1, "A~1"); assert_eq!(r[1].1, "A~2");
assert!(r[1].2); assert!(r[1].2);
assert_eq!(r[2].1, "A~2"); assert_eq!(r[2].1, "A~3");
assert!(r[2].2); assert!(r[2].2);
} }
@ -2004,11 +2004,7 @@ mod tests {
]; ];
let r = process_new_children_with_disk_seed(&["a_b"], &new_children); let r = process_new_children_with_disk_seed(&["a_b"], &new_children);
for (i, (_, filename, needs_meta)) in r.iter().enumerate() { for (i, (_, filename, needs_meta)) in r.iter().enumerate() {
let expected = if i == 0 { let expected = format!("A_B~{}", i + 2);
"A_B~1".to_string()
} else {
format!("A_B~{}", i + 1)
};
assert_eq!(filename, &expected, "child {i}"); assert_eq!(filename, &expected, "child {i}");
assert!(needs_meta, "child {i} needs meta"); assert!(needs_meta, "child {i} needs meta");
} }
@ -2019,8 +2015,8 @@ mod tests {
// Multiple instances with empty names. All slugify to "instance". // Multiple instances with empty names. All slugify to "instance".
let r = process_siblings_dir(&["", "", ""]); let r = process_siblings_dir(&["", "", ""]);
assert_eq!(r[0].1, "instance"); assert_eq!(r[0].1, "instance");
assert_eq!(r[1].1, "instance~1"); assert_eq!(r[1].1, "instance~2");
assert_eq!(r[2].1, "instance~2"); assert_eq!(r[2].1, "instance~3");
} }
#[test] #[test]
@ -2028,10 +2024,10 @@ mod tests {
// Names that are entirely forbidden chars → all become "instance". // Names that are entirely forbidden chars → all become "instance".
let r = process_siblings_dir(&["<>", ":/", "?*", "|\\", "\"~"]); let r = process_siblings_dir(&["<>", ":/", "?*", "|\\", "\"~"]);
assert_eq!(r[0].1, "instance"); assert_eq!(r[0].1, "instance");
assert_eq!(r[1].1, "instance~1"); assert_eq!(r[1].1, "instance~2");
assert_eq!(r[2].1, "instance~2"); assert_eq!(r[2].1, "instance~3");
assert_eq!(r[3].1, "instance~3"); assert_eq!(r[3].1, "instance~4");
assert_eq!(r[4].1, "instance~4"); assert_eq!(r[4].1, "instance~5");
} }
#[test] #[test]
@ -2039,7 +2035,7 @@ mod tests {
// "Hello\x00World" → "Hello_World", "Hello/World" → "Hello_World" // "Hello\x00World" → "Hello_World", "Hello/World" → "Hello_World"
let r = process_siblings_dir(&["Hello\x00World", "Hello/World"]); let r = process_siblings_dir(&["Hello\x00World", "Hello/World"]);
assert_eq!(r[0].1, "Hello_World"); assert_eq!(r[0].1, "Hello_World");
assert_eq!(r[1].1, "Hello_World~1"); assert_eq!(r[1].1, "Hello_World~2");
} }
#[test] #[test]
@ -2059,7 +2055,7 @@ mod tests {
// "init/meta" → slug "init_meta" → collision! // "init/meta" → slug "init_meta" → collision!
let r = process_siblings_dir(&["init.meta", "init/meta"]); let r = process_siblings_dir(&["init.meta", "init/meta"]);
assert_eq!(r[0].1, "init_meta"); assert_eq!(r[0].1, "init_meta");
assert_eq!(r[1].1, "init_meta~1"); assert_eq!(r[1].1, "init_meta~2");
} }
#[test] #[test]
@ -2068,9 +2064,9 @@ mod tests {
let r = process_siblings_dir(&["a.project", "a/project", "a_project"]); let r = process_siblings_dir(&["a.project", "a/project", "a_project"]);
assert_eq!(r[0].1, "a_project"); assert_eq!(r[0].1, "a_project");
assert!(r[0].2); assert!(r[0].2);
assert_eq!(r[1].1, "a_project~1"); assert_eq!(r[1].1, "a_project~2");
assert!(r[1].2); assert!(r[1].2);
assert_eq!(r[2].1, "a_project~2"); assert_eq!(r[2].1, "a_project~3");
assert!(r[2].2, "natural name collides with slugified siblings"); assert!(r[2].2, "natural name collides with slugified siblings");
} }
@ -2082,7 +2078,7 @@ mod tests {
assert_eq!(r[0].1, "Script"); assert_eq!(r[0].1, "Script");
assert!(!r[0].2); assert!(!r[0].2);
for (i, entry) in r.iter().enumerate().skip(1) { for (i, entry) in r.iter().enumerate().skip(1) {
assert_eq!(entry.1, format!("Script~{i}")); assert_eq!(entry.1, format!("Script~{}", i + 1));
assert!(entry.2); assert!(entry.2);
} }
// All filenames must be unique // All filenames must be unique
@ -2097,22 +2093,22 @@ mod tests {
let r = process_siblings_dir(&[ let r = process_siblings_dir(&[
"A_B", // clean, claims "A_B" "A_B", // clean, claims "A_B"
"C_D", // clean, claims "C_D" "C_D", // clean, claims "C_D"
"A/B", // slug "A_B" → collision → "A_B~1" "A/B", // slug "A_B" → collision → "A_B~2"
"E_F", // clean, claims "E_F" "E_F", // clean, claims "E_F"
"C:D", // slug "C_D" → collision → "C_D~1" "C:D", // slug "C_D" → collision → "C_D~2"
"A:B", // slug "A_B" → collision with A_B and A_B~1 → "A_B~2" "A:B", // slug "A_B" → collision with A_B and A_B~2 → "A_B~3"
]); ]);
assert_eq!(r[0].1, "A_B"); assert_eq!(r[0].1, "A_B");
assert!(!r[0].2); assert!(!r[0].2);
assert_eq!(r[1].1, "C_D"); assert_eq!(r[1].1, "C_D");
assert!(!r[1].2); assert!(!r[1].2);
assert_eq!(r[2].1, "A_B~1"); assert_eq!(r[2].1, "A_B~2");
assert!(r[2].2); assert!(r[2].2);
assert_eq!(r[3].1, "E_F"); assert_eq!(r[3].1, "E_F");
assert!(!r[3].2); assert!(!r[3].2);
assert_eq!(r[4].1, "C_D~1"); assert_eq!(r[4].1, "C_D~2");
assert!(r[4].2); assert!(r[4].2);
assert_eq!(r[5].1, "A_B~2"); assert_eq!(r[5].1, "A_B~3");
assert!(r[5].2); assert!(r[5].2);
} }
@ -2317,7 +2313,7 @@ mod tests {
#[test] #[test]
fn tilde_dedup_collision_roundtrip() { fn tilde_dedup_collision_roundtrip() {
// Two instances "A/B" and "A_B" both slugify to "A_B". // Two instances "A/B" and "A_B" both slugify to "A_B".
// First gets "A_B", second gets "A_B~1". // First gets "A_B", second gets "A_B~2".
let mut taken = HashSet::new(); let mut taken = HashSet::new();
let dom1 = make_inst("A/B", "ModuleScript"); let dom1 = make_inst("A/B", "ModuleScript");
@ -2336,8 +2332,8 @@ mod tests {
assert_eq!(name1.as_ref(), "A_B.luau"); assert_eq!(name1.as_ref(), "A_B.luau");
assert!(meta1, "A/B was slugified, needs meta name"); assert!(meta1, "A/B was slugified, needs meta name");
assert_eq!(name2.as_ref(), "A_B~1.luau"); assert_eq!(name2.as_ref(), "A_B~2.luau");
assert!(meta2, "A_B was deduped to ~1, needs meta name"); assert!(meta2, "A_B was deduped to ~2, needs meta name");
} }
#[test] #[test]
@ -2486,7 +2482,7 @@ mod tests {
assert_eq!(f1.as_ref(), "A_B.luau"); assert_eq!(f1.as_ref(), "A_B.luau");
assert!(m1); assert!(m1);
assert_eq!(f2.as_ref(), "A_B~1.luau"); assert_eq!(f2.as_ref(), "A_B~2.luau");
assert!(m2); assert!(m2);
assert_ne!(dk1.to_lowercase(), dk2.to_lowercase()); assert_ne!(dk1.to_lowercase(), dk2.to_lowercase());
} }

View file

@ -535,7 +535,7 @@ mod tests {
meta_content.as_bytes().to_vec(), meta_content.as_bytes().to_vec(),
); );
let substitutions = vec![("Workspace/Foo".to_string(), "Workspace/Foo~1".to_string())]; let substitutions = vec![("Workspace/Foo".to_string(), "Workspace/Foo~2".to_string())];
snap.fix_ref_paths(&substitutions); snap.fix_ref_paths(&substitutions);
let result = std::str::from_utf8( let result = std::str::from_utf8(
@ -546,7 +546,7 @@ mod tests {
.unwrap(); .unwrap();
assert!( assert!(
result.contains(r#""Rojo_Ref_PrimaryPart": "Workspace/Foo~1""#), result.contains(r#""Rojo_Ref_PrimaryPart": "Workspace/Foo~2""#),
"Rojo_Ref line should be updated. Got:\n{}", "Rojo_Ref line should be updated. Got:\n{}",
result result
); );
@ -566,7 +566,7 @@ mod tests {
b"-- Rojo_Ref_PrimaryPart Workspace/Foo".to_vec(), b"-- Rojo_Ref_PrimaryPart Workspace/Foo".to_vec(),
); );
let substitutions = vec![("Workspace/Foo".to_string(), "Workspace/Foo~1".to_string())]; let substitutions = vec![("Workspace/Foo".to_string(), "Workspace/Foo~2".to_string())];
snap.fix_ref_paths(&substitutions); snap.fix_ref_paths(&substitutions);
let result = std::str::from_utf8( let result = std::str::from_utf8(
@ -581,7 +581,7 @@ mod tests {
result result
); );
assert!( assert!(
!result.contains("Workspace/Foo~1"), !result.contains("Workspace/Foo~2"),
"Non-meta file should not have substitution applied" "Non-meta file should not have substitution applied"
); );
} }

View file

@ -181,7 +181,7 @@ pub fn collect_all_paths(dom: &WeakDom) -> HashMap<Ref, String> {
/// ///
/// The `final_paths` parameter, when provided, contains the definitive /// The `final_paths` parameter, when provided, contains the definitive
/// filesystem-name-based paths assigned during the syncback walk (including /// filesystem-name-based paths assigned during the syncback walk (including
/// dedup suffixes like `~1`). These take priority over `tentative_fs_path()`. /// dedup suffixes like `~2`). These take priority over `tentative_fs_path()`.
pub fn collect_referents( pub fn collect_referents(
dom: &WeakDom, dom: &WeakDom,
pre_prune_paths: &HashMap<Ref, String>, pre_prune_paths: &HashMap<Ref, String>,

View file

@ -553,7 +553,7 @@ impl ApiService {
// Pre-seed sibling_slugs from the tree's existing children // Pre-seed sibling_slugs from the tree's existing children
// of this parent so new instances dedup against existing ones. // of this parent so new instances dedup against existing ones.
// We derive slugs from actual filesystem paths (via instigating_source) // We derive slugs from actual filesystem paths (via instigating_source)
// to correctly account for dedup suffixes (e.g., Hey_Bro~1). // to correctly account for dedup suffixes (e.g., Hey_Bro~2).
let mut sibling_slugs: HashSet<String> = if *parent_ref != Ref::none() { let mut sibling_slugs: HashSet<String> = if *parent_ref != Ref::none() {
if let Some(parent_inst) = tree.get_instance(*parent_ref) { if let Some(parent_inst) = tree.get_instance(*parent_ref) {
use crate::snapshot::InstigatingSource; use crate::snapshot::InstigatingSource;
@ -6436,7 +6436,7 @@ mod tests {
#[test] #[test]
fn dir_clean_name_collides_with_existing() { fn dir_clean_name_collides_with_existing() {
let (f, m) = simulate_dir_dedup(&["NewFolder"], "NewFolder"); let (f, m) = simulate_dir_dedup(&["NewFolder"], "NewFolder");
assert_eq!(f, "NewFolder~1"); assert_eq!(f, "NewFolder~2");
assert_eq!(m.unwrap(), "NewFolder"); assert_eq!(m.unwrap(), "NewFolder");
} }
@ -6450,21 +6450,21 @@ mod tests {
#[test] #[test]
fn dir_forbidden_collides_with_existing() { fn dir_forbidden_collides_with_existing() {
let (f, m) = simulate_dir_dedup(&["Hey_Bro"], "Hey/Bro"); let (f, m) = simulate_dir_dedup(&["Hey_Bro"], "Hey/Bro");
assert_eq!(f, "Hey_Bro~1"); assert_eq!(f, "Hey_Bro~2");
assert_eq!(m.unwrap(), "Hey/Bro"); assert_eq!(m.unwrap(), "Hey/Bro");
} }
#[test] #[test]
fn dir_natural_collides_with_slug() { fn dir_natural_collides_with_slug() {
let (f, m) = simulate_dir_dedup(&["A_B"], "A_B"); let (f, m) = simulate_dir_dedup(&["A_B"], "A_B");
assert_eq!(f, "A_B~1"); assert_eq!(f, "A_B~2");
assert_eq!(m.unwrap(), "A_B"); assert_eq!(m.unwrap(), "A_B");
} }
#[test] #[test]
fn dir_case_insensitive_collision() { fn dir_case_insensitive_collision() {
let (f, m) = simulate_dir_dedup(&["MyFolder"], "myfolder"); let (f, m) = simulate_dir_dedup(&["MyFolder"], "myfolder");
assert_eq!(f, "myfolder~1"); assert_eq!(f, "myfolder~2");
assert_eq!(m.unwrap(), "myfolder"); assert_eq!(m.unwrap(), "myfolder");
} }
@ -6490,7 +6490,7 @@ mod tests {
#[test] #[test]
fn dir_dangerous_suffix_collides() { fn dir_dangerous_suffix_collides() {
let (f, m) = simulate_dir_dedup(&["config_meta"], "config.meta"); let (f, m) = simulate_dir_dedup(&["config_meta"], "config.meta");
assert_eq!(f, "config_meta~1"); assert_eq!(f, "config_meta~2");
assert_eq!(m.unwrap(), "config.meta"); assert_eq!(m.unwrap(), "config.meta");
} }
@ -6504,7 +6504,7 @@ mod tests {
#[test] #[test]
fn dir_nul_collides_with_existing() { fn dir_nul_collides_with_existing() {
let (f, _) = simulate_dir_dedup(&["NUL_"], "NUL"); let (f, _) = simulate_dir_dedup(&["NUL_"], "NUL");
assert_eq!(f, "NUL_~1"); assert_eq!(f, "NUL_~2");
} }
// ── Batch add: directory siblings ───────────────────────────── // ── Batch add: directory siblings ─────────────────────────────
@ -6523,24 +6523,24 @@ mod tests {
let r = simulate_dir_batch(&[], &["Script", "Script", "Script"]); let r = simulate_dir_batch(&[], &["Script", "Script", "Script"]);
assert_eq!(r[0].0, "Script"); assert_eq!(r[0].0, "Script");
assert!(r[0].1.is_none()); assert!(r[0].1.is_none());
assert_eq!(r[1].0, "Script~1"); assert_eq!(r[1].0, "Script~2");
assert_eq!(r[1].1.as_deref(), Some("Script")); assert_eq!(r[1].1.as_deref(), Some("Script"));
assert_eq!(r[2].0, "Script~2"); assert_eq!(r[2].0, "Script~3");
} }
#[test] #[test]
fn batch_dir_slug_collision_siblings() { fn batch_dir_slug_collision_siblings() {
let r = simulate_dir_batch(&[], &["X/Y", "X:Y", "X*Y"]); let r = simulate_dir_batch(&[], &["X/Y", "X:Y", "X*Y"]);
assert_eq!(r[0].0, "X_Y"); assert_eq!(r[0].0, "X_Y");
assert_eq!(r[1].0, "X_Y~1"); assert_eq!(r[1].0, "X_Y~2");
assert_eq!(r[2].0, "X_Y~2"); assert_eq!(r[2].0, "X_Y~3");
} }
#[test] #[test]
fn batch_dir_with_existing_and_collisions() { fn batch_dir_with_existing_and_collisions() {
let r = simulate_dir_batch(&["Utils", "Config"], &["Utils", "Config", "NewThing"]); let r = simulate_dir_batch(&["Utils", "Config"], &["Utils", "Config", "NewThing"]);
assert_eq!(r[0].0, "Utils~1"); assert_eq!(r[0].0, "Utils~2");
assert_eq!(r[1].0, "Config~1"); assert_eq!(r[1].0, "Config~2");
assert_eq!(r[2].0, "NewThing"); assert_eq!(r[2].0, "NewThing");
assert!(r[2].1.is_none()); assert!(r[2].1.is_none());
} }
@ -6549,8 +6549,8 @@ mod tests {
fn batch_dir_dangerous_then_natural_then_slug() { fn batch_dir_dangerous_then_natural_then_slug() {
let r = simulate_dir_batch(&[], &["foo.server", "foo_server", "foo/server"]); let r = simulate_dir_batch(&[], &["foo.server", "foo_server", "foo/server"]);
assert_eq!(r[0].0, "foo_server"); assert_eq!(r[0].0, "foo_server");
assert_eq!(r[1].0, "foo_server~1"); assert_eq!(r[1].0, "foo_server~2");
assert_eq!(r[2].0, "foo_server~2"); assert_eq!(r[2].0, "foo_server~3");
} }
#[test] #[test]
@ -6558,9 +6558,9 @@ mod tests {
let r = simulate_dir_batch(&[], &["CON", "CON_", "con"]); let r = simulate_dir_batch(&[], &["CON", "CON_", "con"]);
assert_eq!(r[0].0, "CON_"); assert_eq!(r[0].0, "CON_");
assert_eq!(r[0].1.as_deref(), Some("CON")); assert_eq!(r[0].1.as_deref(), Some("CON"));
assert_eq!(r[1].0, "CON_~1"); assert_eq!(r[1].0, "CON_~2");
assert_eq!(r[1].1.as_deref(), Some("CON_")); assert_eq!(r[1].1.as_deref(), Some("CON_"));
assert_eq!(r[2].0, "con_~2"); assert_eq!(r[2].0, "con_~3");
assert_eq!(r[2].1.as_deref(), Some("con")); assert_eq!(r[2].1.as_deref(), Some("con"));
} }
@ -6568,8 +6568,8 @@ mod tests {
fn batch_dir_empty_names() { fn batch_dir_empty_names() {
let r = simulate_dir_batch(&[], &["", "", ""]); let r = simulate_dir_batch(&[], &["", "", ""]);
assert_eq!(r[0].0, "instance"); assert_eq!(r[0].0, "instance");
assert_eq!(r[1].0, "instance~1"); assert_eq!(r[1].0, "instance~2");
assert_eq!(r[2].0, "instance~2"); assert_eq!(r[2].0, "instance~3");
} }
#[test] #[test]
@ -6578,7 +6578,7 @@ mod tests {
let r = simulate_dir_batch(&[], &children); let r = simulate_dir_batch(&[], &children);
assert_eq!(r[0].0, "Spam"); assert_eq!(r[0].0, "Spam");
for (i, entry) in r.iter().enumerate().skip(1) { for (i, entry) in r.iter().enumerate().skip(1) {
assert_eq!(entry.0, format!("Spam~{i}")); assert_eq!(entry.0, format!("Spam~{}", i + 1));
} }
let unique: HashSet<String> = r.iter().map(|(f, _)| f.to_lowercase()).collect(); let unique: HashSet<String> = r.iter().map(|(f, _)| f.to_lowercase()).collect();
assert_eq!(unique.len(), 20); assert_eq!(unique.len(), 20);
@ -6594,7 +6594,7 @@ mod tests {
let taken: HashSet<String> = ["foo".to_string()].into_iter().collect(); let taken: HashSet<String> = ["foo".to_string()].into_iter().collect();
// Bare slug "Foo" matches "foo" in taken // Bare slug "Foo" matches "foo" in taken
let deduped = deduplicate_name("Foo", &taken); let deduped = deduplicate_name("Foo", &taken);
assert_eq!(deduped, "Foo~1", "bare slug collides with existing sibling"); assert_eq!(deduped, "Foo~2", "bare slug collides with existing sibling");
} }
#[test] #[test]
@ -6605,7 +6605,7 @@ mod tests {
let slug = slugify_name("Hey/Bro"); let slug = slugify_name("Hey/Bro");
assert_eq!(slug, "Hey_Bro"); assert_eq!(slug, "Hey_Bro");
let deduped = deduplicate_name(&slug, &taken); let deduped = deduplicate_name(&slug, &taken);
assert_eq!(deduped, "Hey_Bro~1", "slug collision correctly detected"); assert_eq!(deduped, "Hey_Bro~2", "slug collision correctly detected");
} }
// ── Rename → re-add cycle (the critical slug safety test) ──── // ── Rename → re-add cycle (the critical slug safety test) ────
@ -6656,7 +6656,7 @@ mod tests {
#[test] #[test]
fn dir_add_to_populated_no_overwrite() { fn dir_add_to_populated_no_overwrite() {
// Dir has: Helper/ and Helper.meta.json5 // Dir has: Helper/ and Helper.meta.json5
// Add new "Helper" dir → must get Helper~1 // Add new "Helper" dir → must get Helper~2
let temp = tempdir().expect("Failed to create temp dir"); let temp = tempdir().expect("Failed to create temp dir");
fs::create_dir(temp.path().join("Helper")).unwrap(); fs::create_dir(temp.path().join("Helper")).unwrap();
File::create(temp.path().join("Helper.meta.json5")) File::create(temp.path().join("Helper.meta.json5"))
@ -6672,7 +6672,7 @@ mod tests {
.collect(); .collect();
let deduped = deduplicate_name("Helper", &taken); let deduped = deduplicate_name("Helper", &taken);
assert_eq!(deduped, "Helper~1"); assert_eq!(deduped, "Helper~2");
} }
// ── Nightmare batch scenarios ──────────────────────────────── // ── Nightmare batch scenarios ────────────────────────────────
@ -6685,15 +6685,15 @@ mod tests {
"A/B", "A:B", "A*B", "A?B", "A<B", "A>B", "A|B", "A\\B", "A\"B", "A/B", "A:B", "A*B", "A?B", "A<B", "A>B", "A|B", "A\\B", "A\"B",
], ],
); );
assert_eq!(r[0].0, "A_B~1"); assert_eq!(r[0].0, "A_B~2");
assert_eq!(r[1].0, "A_B~2"); assert_eq!(r[1].0, "A_B~3");
assert_eq!(r[2].0, "A_B~3"); assert_eq!(r[2].0, "A_B~4");
assert_eq!(r[3].0, "A_B~4"); assert_eq!(r[3].0, "A_B~5");
assert_eq!(r[4].0, "A_B~5"); assert_eq!(r[4].0, "A_B~6");
assert_eq!(r[5].0, "A_B~6"); assert_eq!(r[5].0, "A_B~7");
assert_eq!(r[6].0, "A_B~7"); assert_eq!(r[6].0, "A_B~8");
assert_eq!(r[7].0, "A_B~8"); assert_eq!(r[7].0, "A_B~9");
assert_eq!(r[8].0, "A_B~9"); assert_eq!(r[8].0, "A_B~10");
} }
#[test] #[test]
@ -6701,16 +6701,16 @@ mod tests {
let r = simulate_dir_batch(&[], &["CON", "PRN", "CON/", "PRN/"]); let r = simulate_dir_batch(&[], &["CON", "PRN", "CON/", "PRN/"]);
assert_eq!(r[0].0, "CON_"); assert_eq!(r[0].0, "CON_");
assert_eq!(r[1].0, "PRN_"); assert_eq!(r[1].0, "PRN_");
assert_eq!(r[2].0, "CON_~1"); // "CON/" slug "CON_" collides assert_eq!(r[2].0, "CON_~2"); // "CON/" slug "CON_" collides
assert_eq!(r[3].0, "PRN_~1"); assert_eq!(r[3].0, "PRN_~2");
} }
#[test] #[test]
fn batch_dir_unicode_plus_forbidden() { fn batch_dir_unicode_plus_forbidden() {
let r = simulate_dir_batch(&[], &["カフェ/Bar", "カフェ:Bar", "カフェ_Bar"]); let r = simulate_dir_batch(&[], &["カフェ/Bar", "カフェ:Bar", "カフェ_Bar"]);
assert_eq!(r[0].0, "カフェ_Bar"); assert_eq!(r[0].0, "カフェ_Bar");
assert_eq!(r[1].0, "カフェ_Bar~1"); assert_eq!(r[1].0, "カフェ_Bar~2");
assert_eq!(r[2].0, "カフェ_Bar~2"); assert_eq!(r[2].0, "カフェ_Bar~3");
} }
#[test] #[test]

View file

@ -1044,14 +1044,14 @@ fn delete_meta_name_reverts_to_stem() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Test 28: Edit dedup file with meta. Patch should show instance named /// Test 28: Edit dedup file with meta. Patch should show instance named
/// "Hey/Bro" (from meta), not "Hey_Bro~1". /// "Hey/Bro" (from meta), not "Hey_Bro~2".
#[test] #[test]
fn edit_dedup_file_with_meta() { fn edit_dedup_file_with_meta() {
run_serve_test("connected_slugify", |session, mut redactions| { run_serve_test("connected_slugify", |session, mut redactions| {
let info = session.get_api_rojo().unwrap(); let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id; let root_id = info.root_instance_id;
let file_path = session.path().join("src/Hey_Bro~1.luau"); let file_path = session.path().join("src/Hey_Bro~2.luau");
let socket_packet = session let socket_packet = session
.recv_socket_packet(SocketPacketType::Messages, 0, || { .recv_socket_packet(SocketPacketType::Messages, 0, || {
@ -1110,8 +1110,8 @@ fn remove_one_of_two_colliding_files() {
let socket_packet = session let socket_packet = session
.recv_socket_packet(SocketPacketType::Messages, 0, || { .recv_socket_packet(SocketPacketType::Messages, 0, || {
fs::remove_file(src.join("Hey_Bro~1.luau")).unwrap(); fs::remove_file(src.join("Hey_Bro~2.luau")).unwrap();
fs::remove_file(src.join("Hey_Bro~1.meta.json5")).unwrap(); fs::remove_file(src.join("Hey_Bro~2.meta.json5")).unwrap();
}) })
.unwrap(); .unwrap();
assert_yaml_snapshot!( assert_yaml_snapshot!(
@ -1138,9 +1138,9 @@ fn add_third_collision() {
let socket_packet = session let socket_packet = session
.recv_socket_packet(SocketPacketType::Messages, 0, || { .recv_socket_packet(SocketPacketType::Messages, 0, || {
fs::write(src.join("Hey_Bro~2.meta.json5"), r#"{ "name": "Hey*Bro" }"#).unwrap(); fs::write(src.join("Hey_Bro~3.meta.json5"), r#"{ "name": "Hey*Bro" }"#).unwrap();
fs::write( fs::write(
src.join("Hey_Bro~2.luau"), src.join("Hey_Bro~3.luau"),
"-- third collision\nreturn 'Hey*Bro'", "-- third collision\nreturn 'Hey*Bro'",
) )
.unwrap(); .unwrap();
@ -1429,7 +1429,7 @@ fn slugify_rename_chain() {
} }
/// Test 41: Collision evolution. Add third collision, remove one, reuse /// Test 41: Collision evolution. Add third collision, remove one, reuse
/// the ~1 slot with a different name. Round-trip at each step. /// the ~2 slot with a different name. Round-trip at each step.
#[test] #[test]
fn collision_evolution() { fn collision_evolution() {
run_serve_test("connected_slugify", |session, mut redactions| { run_serve_test("connected_slugify", |session, mut redactions| {
@ -1442,11 +1442,11 @@ fn collision_evolution() {
let src = session.path().join("src"); let src = session.path().join("src");
// Step 1: Add third collision Hey_Bro~2 -> "Hey*Bro" // Step 1: Add third collision Hey_Bro~3 -> "Hey*Bro"
let packet = session let packet = session
.recv_socket_packet(SocketPacketType::Messages, 0, || { .recv_socket_packet(SocketPacketType::Messages, 0, || {
fs::write(src.join("Hey_Bro~2.meta.json5"), r#"{ "name": "Hey*Bro" }"#).unwrap(); fs::write(src.join("Hey_Bro~3.meta.json5"), r#"{ "name": "Hey*Bro" }"#).unwrap();
fs::write(src.join("Hey_Bro~2.luau"), "return 'Hey*Bro'").unwrap(); fs::write(src.join("Hey_Bro~3.luau"), "return 'Hey*Bro'").unwrap();
}) })
.unwrap(); .unwrap();
assert_yaml_snapshot!( assert_yaml_snapshot!(
@ -1455,12 +1455,12 @@ fn collision_evolution() {
); );
assert_round_trip(&session, root_id); assert_round_trip(&session, root_id);
// Step 2: Remove Hey_Bro~1 (Hey/Bro) // Step 2: Remove Hey_Bro~2 (Hey/Bro)
let cursor = get_message_cursor(&packet); let cursor = get_message_cursor(&packet);
let packet = session let packet = session
.recv_socket_packet(SocketPacketType::Messages, cursor, || { .recv_socket_packet(SocketPacketType::Messages, cursor, || {
fs::remove_file(src.join("Hey_Bro~1.luau")).unwrap(); fs::remove_file(src.join("Hey_Bro~2.luau")).unwrap();
fs::remove_file(src.join("Hey_Bro~1.meta.json5")).unwrap(); fs::remove_file(src.join("Hey_Bro~2.meta.json5")).unwrap();
}) })
.unwrap(); .unwrap();
assert_yaml_snapshot!( assert_yaml_snapshot!(
@ -1469,12 +1469,12 @@ fn collision_evolution() {
); );
assert_round_trip(&session, root_id); assert_round_trip(&session, root_id);
// Step 3: Reuse ~1 slot with different name "Hey|Bro" // Step 3: Reuse ~2 slot with different name "Hey|Bro"
let cursor = get_message_cursor(&packet); let cursor = get_message_cursor(&packet);
let packet = session let packet = session
.recv_socket_packet(SocketPacketType::Messages, cursor, || { .recv_socket_packet(SocketPacketType::Messages, cursor, || {
fs::write(src.join("Hey_Bro~1.meta.json5"), r#"{ "name": "Hey|Bro" }"#).unwrap(); fs::write(src.join("Hey_Bro~2.meta.json5"), r#"{ "name": "Hey|Bro" }"#).unwrap();
fs::write(src.join("Hey_Bro~1.luau"), "return 'Hey|Bro'").unwrap(); fs::write(src.join("Hey_Bro~2.luau"), "return 'Hey|Bro'").unwrap();
}) })
.unwrap(); .unwrap();
assert_yaml_snapshot!( assert_yaml_snapshot!(

View file

@ -5164,7 +5164,7 @@ fn add_instance_with_forbidden_chars_creates_slug_and_meta() {
} }
/// Adding two instances whose names slugify to the same string should produce /// Adding two instances whose names slugify to the same string should produce
/// dedup suffixes (~1) and both should have correct meta `name` fields. /// dedup suffixes (~2) and both should have correct meta `name` fields.
#[test] #[test]
fn add_two_colliding_instances_deduplicates() { fn add_two_colliding_instances_deduplicates() {
run_serve_test("syncback_encoded_names", |session, _redactions| { run_serve_test("syncback_encoded_names", |session, _redactions| {
@ -5215,12 +5215,12 @@ fn add_two_colliding_instances_deduplicates() {
let src = session.path().join("src"); let src = session.path().join("src");
let base = src.join("X_Y.luau"); let base = src.join("X_Y.luau");
let deduped = src.join("X_Y~1.luau"); let deduped = src.join("X_Y~2.luau");
// One should get X_Y.luau, the other X_Y~1.luau // One should get X_Y.luau, the other X_Y~2.luau
assert!( assert!(
base.exists() && deduped.exists(), base.exists() && deduped.exists(),
"Both X_Y.luau and X_Y~1.luau should exist. \ "Both X_Y.luau and X_Y~2.luau should exist. \
base exists: {}, deduped exists: {}", base exists: {}, deduped exists: {}",
base.exists(), base.exists(),
deduped.exists() deduped.exists()
@ -5228,9 +5228,9 @@ fn add_two_colliding_instances_deduplicates() {
// Both should have meta files with correct names // Both should have meta files with correct names
let base_meta = src.join("X_Y.meta.json5"); let base_meta = src.join("X_Y.meta.json5");
let deduped_meta = src.join("X_Y~1.meta.json5"); let deduped_meta = src.join("X_Y~2.meta.json5");
assert_file_exists(&base_meta, "Meta for base slug instance"); assert_file_exists(&base_meta, "Meta for base slug instance");
assert_file_exists(&deduped_meta, "Meta for deduped ~1 instance"); assert_file_exists(&deduped_meta, "Meta for deduped ~2 instance");
// Read both meta files and verify they contain the correct names // Read both meta files and verify they contain the correct names
let meta1 = fs::read_to_string(&base_meta).unwrap(); let meta1 = fs::read_to_string(&base_meta).unwrap();
@ -5456,7 +5456,7 @@ fn rename_model_json_to_clean_name() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Adding 3 instances that all slug to the same name (X/Y, X:Y, X|Y → X_Y) /// 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. /// should produce X_Y.luau, X_Y~2.luau, X_Y~3.luau with correct meta files.
#[test] #[test]
fn add_three_colliding_instances_deduplicates() { fn add_three_colliding_instances_deduplicates() {
run_serve_test("syncback_encoded_names", |session, _redactions| { run_serve_test("syncback_encoded_names", |session, _redactions| {
@ -5495,13 +5495,13 @@ fn add_three_colliding_instances_deduplicates() {
let src = session.path().join("src"); let src = session.path().join("src");
let base = src.join("X_Y.luau"); let base = src.join("X_Y.luau");
let dedup1 = src.join("X_Y~1.luau"); let dedup1 = src.join("X_Y~2.luau");
let dedup2 = src.join("X_Y~2.luau"); let dedup2 = src.join("X_Y~3.luau");
assert!( assert!(
base.exists() && dedup1.exists() && dedup2.exists(), base.exists() && dedup1.exists() && dedup2.exists(),
"All three slugified files should exist.\n\ "All three slugified files should exist.\n\
X_Y.luau: {}, X_Y~1.luau: {}, X_Y~2.luau: {}", X_Y.luau: {}, X_Y~2.luau: {}, X_Y~3.luau: {}",
base.exists(), base.exists(),
dedup1.exists(), dedup1.exists(),
dedup2.exists() dedup2.exists()
@ -5509,11 +5509,11 @@ fn add_three_colliding_instances_deduplicates() {
// All three should have meta files with the original names // All three should have meta files with the original names
let base_meta = src.join("X_Y.meta.json5"); let base_meta = src.join("X_Y.meta.json5");
let dedup1_meta = src.join("X_Y~1.meta.json5"); let dedup1_meta = src.join("X_Y~2.meta.json5");
let dedup2_meta = src.join("X_Y~2.meta.json5"); let dedup2_meta = src.join("X_Y~3.meta.json5");
assert_file_exists(&base_meta, "Meta for base slug"); assert_file_exists(&base_meta, "Meta for base slug");
assert_file_exists(&dedup1_meta, "Meta for ~1 dedup"); assert_file_exists(&dedup1_meta, "Meta for ~2 dedup");
assert_file_exists(&dedup2_meta, "Meta for ~2 dedup"); assert_file_exists(&dedup2_meta, "Meta for ~3 dedup");
// Each meta should contain one of the original names // Each meta should contain one of the original names
let all_metas = [ let all_metas = [
@ -5662,13 +5662,13 @@ fn add_case_insensitive_colliding_instances() {
let src = session.path().join("src"); let src = session.path().join("src");
// One should get the base name, the other should get ~1 // One should get the base name, the other should get ~2
// The exact casing depends on processing order, but both files // The exact casing depends on processing order, but both files
// must exist with distinct names. // must exist with distinct names.
let base = src.join("CaseTest.luau"); let base = src.join("CaseTest.luau");
let base_lower = src.join("casetest.luau"); let base_lower = src.join("casetest.luau");
let dedup = src.join("CaseTest~1.luau"); let dedup = src.join("CaseTest~2.luau");
let dedup_lower = src.join("casetest~1.luau"); let dedup_lower = src.join("casetest~2.luau");
let base_exists = base.exists() || base_lower.exists(); let base_exists = base.exists() || base_lower.exists();
let dedup_exists = dedup.exists() || dedup_lower.exists(); let dedup_exists = dedup.exists() || dedup_lower.exists();
@ -5676,7 +5676,7 @@ fn add_case_insensitive_colliding_instances() {
assert!( assert!(
base_exists && dedup_exists, base_exists && dedup_exists,
"Both case-insensitive colliding instances should exist as separate files.\n\ "Both case-insensitive colliding instances should exist as separate files.\n\
CaseTest.luau: {}, casetest.luau: {}, CaseTest~1.luau: {}, casetest~1.luau: {}", CaseTest.luau: {}, casetest.luau: {}, CaseTest~2.luau: {}, casetest~2.luau: {}",
base.exists(), base.exists(),
base_lower.exists(), base_lower.exists(),
dedup.exists(), dedup.exists(),
@ -7406,7 +7406,7 @@ fn ref_same_batch_add_folder_target_no_extension() {
// Tests: Dedup suffix cleanup // Tests: Dedup suffix cleanup
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Add two colliding ModuleScripts (creates X_Y.luau + X_Y~1.luau), then /// Add two colliding ModuleScripts (creates X_Y.luau + X_Y~2.luau), then
/// delete one. The remaining instance should lose its dedup suffix (group-to-1 /// delete one. The remaining instance should lose its dedup suffix (group-to-1
/// cleanup rule). /// cleanup rule).
#[test] #[test]
@ -7460,10 +7460,10 @@ fn delete_deduped_instance_group_to_1_cleanup() {
let src = session.path().join("src"); let src = session.path().join("src");
let base = src.join("X_Y.luau"); let base = src.join("X_Y.luau");
let deduped = src.join("X_Y~1.luau"); let deduped = src.join("X_Y~2.luau");
poll_file_exists(&base, "X_Y.luau after add"); poll_file_exists(&base, "X_Y.luau after add");
poll_file_exists(&deduped, "X_Y~1.luau after add"); poll_file_exists(&deduped, "X_Y~2.luau after add");
// Wait for VFS to settle // Wait for VFS to settle
thread::sleep(Duration::from_millis(500)); thread::sleep(Duration::from_millis(500));
@ -7485,7 +7485,7 @@ fn delete_deduped_instance_group_to_1_cleanup() {
} }
} else { } else {
// Base file has the survivor source -- the other one is doomed // Base file has the survivor source -- the other one is doomed
let dedup_meta = src.join("X_Y~1.meta.json5"); let dedup_meta = src.join("X_Y~2.meta.json5");
let meta_content = fs::read_to_string(&dedup_meta).unwrap(); let meta_content = fs::read_to_string(&dedup_meta).unwrap();
if meta_content.contains("\"X/Y\"") { if meta_content.contains("\"X/Y\"") {
"X/Y" "X/Y"
@ -7497,8 +7497,8 @@ fn delete_deduped_instance_group_to_1_cleanup() {
let (doomed_id, _) = find_by_name(&rs_read.instances, doomed_name); let (doomed_id, _) = find_by_name(&rs_read.instances, doomed_name);
send_removal(&session, &info.session_id, vec![doomed_id]); send_removal(&session, &info.session_id, vec![doomed_id]);
// After group-to-1 cleanup: only X_Y.luau should remain, no ~1 suffix // After group-to-1 cleanup: only X_Y.luau should remain, no ~2 suffix
poll_not_exists(&deduped, "X_Y~1.luau after group-to-1 cleanup"); poll_not_exists(&deduped, "X_Y~2.luau after group-to-1 cleanup");
assert_file_exists(&base, "X_Y.luau should survive"); assert_file_exists(&base, "X_Y.luau should survive");
// The survivor's meta should still have a name field (since it's slugified) // The survivor's meta should still have a name field (since it's slugified)
@ -7508,7 +7508,7 @@ fn delete_deduped_instance_group_to_1_cleanup() {
} }
/// Add three colliding ModuleScripts, then delete the base instance. /// Add three colliding ModuleScripts, then delete the base instance.
/// The ~1 instance should be promoted to base (base-name promotion rule). /// The ~2 instance should be promoted to base (base-name promotion rule).
#[test] #[test]
fn delete_base_deduped_instance_promotes_lowest() { fn delete_base_deduped_instance_promotes_lowest() {
run_serve_test("syncback_encoded_names", |session, _redactions| { run_serve_test("syncback_encoded_names", |session, _redactions| {
@ -7546,12 +7546,12 @@ fn delete_base_deduped_instance_promotes_lowest() {
let src = session.path().join("src"); let src = session.path().join("src");
let base = src.join("X_Y.luau"); let base = src.join("X_Y.luau");
let dedup1 = src.join("X_Y~1.luau"); let dedup1 = src.join("X_Y~2.luau");
let dedup2 = src.join("X_Y~2.luau"); let dedup2 = src.join("X_Y~3.luau");
poll_file_exists(&base, "X_Y.luau after add"); poll_file_exists(&base, "X_Y.luau after add");
poll_file_exists(&dedup1, "X_Y~1.luau after add"); poll_file_exists(&dedup1, "X_Y~2.luau after add");
poll_file_exists(&dedup2, "X_Y~2.luau after add"); poll_file_exists(&dedup2, "X_Y~3.luau after add");
// Wait for VFS to settle // Wait for VFS to settle
thread::sleep(Duration::from_millis(500)); thread::sleep(Duration::from_millis(500));
@ -7575,19 +7575,19 @@ fn delete_base_deduped_instance_promotes_lowest() {
// Delete the base instance // Delete the base instance
send_removal(&session, &info.session_id, vec![base_id]); send_removal(&session, &info.session_id, vec![base_id]);
// After base-name promotion: ~1 should be promoted to base // After base-name promotion: ~2 should be promoted to base
// X_Y.luau should exist (promoted from ~1) // X_Y.luau should exist (promoted from ~2)
// X_Y~2.luau should still exist (unchanged) // X_Y~3.luau should still exist (unchanged)
// X_Y~1.luau should NOT exist (promoted away) // X_Y~2.luau should NOT exist (promoted away)
poll_not_exists(&dedup1, "X_Y~1.luau after promotion"); poll_not_exists(&dedup1, "X_Y~2.luau after promotion");
thread::sleep(Duration::from_millis(300)); thread::sleep(Duration::from_millis(300));
assert_file_exists(&base, "X_Y.luau should exist (promoted)"); assert_file_exists(&base, "X_Y.luau should exist (promoted)");
assert_file_exists(&dedup2, "X_Y~2.luau should remain"); assert_file_exists(&dedup2, "X_Y~3.luau should remain");
}); });
} }
/// Add three colliding ModuleScripts, then delete the ~1 instance. /// Add three colliding ModuleScripts, then delete the ~2 instance.
/// Gap should be tolerated: X_Y.luau and X_Y~2.luau remain unchanged. /// Gap should be tolerated: X_Y.luau and X_Y~3.luau remain unchanged.
#[test] #[test]
fn delete_middle_deduped_instance_tolerates_gap() { fn delete_middle_deduped_instance_tolerates_gap() {
run_serve_test("syncback_encoded_names", |session, _redactions| { run_serve_test("syncback_encoded_names", |session, _redactions| {
@ -7625,18 +7625,18 @@ fn delete_middle_deduped_instance_tolerates_gap() {
let src = session.path().join("src"); let src = session.path().join("src");
let base = src.join("X_Y.luau"); let base = src.join("X_Y.luau");
let dedup1 = src.join("X_Y~1.luau"); let dedup1 = src.join("X_Y~2.luau");
let dedup2 = src.join("X_Y~2.luau"); let dedup2 = src.join("X_Y~3.luau");
poll_file_exists(&base, "X_Y.luau after add"); poll_file_exists(&base, "X_Y.luau after add");
poll_file_exists(&dedup1, "X_Y~1.luau after add"); poll_file_exists(&dedup1, "X_Y~2.luau after add");
poll_file_exists(&dedup2, "X_Y~2.luau after add"); poll_file_exists(&dedup2, "X_Y~3.luau after add");
thread::sleep(Duration::from_millis(500)); thread::sleep(Duration::from_millis(500));
// Find which instance maps to ~1 // Find which instance maps to ~2
let rs_read = session.get_api_read(rs_id).unwrap(); let rs_read = session.get_api_read(rs_id).unwrap();
let dedup1_meta = src.join("X_Y~1.meta.json5"); let dedup1_meta = src.join("X_Y~2.meta.json5");
let dedup1_meta_content = fs::read_to_string(&dedup1_meta).unwrap(); let dedup1_meta_content = fs::read_to_string(&dedup1_meta).unwrap();
let middle_name = if dedup1_meta_content.contains("\"X/Y\"") { let middle_name = if dedup1_meta_content.contains("\"X/Y\"") {
"X/Y" "X/Y"
@ -7648,16 +7648,16 @@ fn delete_middle_deduped_instance_tolerates_gap() {
let (middle_id, _) = find_by_name(&rs_read.instances, middle_name); let (middle_id, _) = find_by_name(&rs_read.instances, middle_name);
// Delete the ~1 instance // Delete the ~2 instance
send_removal(&session, &info.session_id, vec![middle_id]); send_removal(&session, &info.session_id, vec![middle_id]);
// Gap tolerance: base and ~2 remain, ~1 is gone // Gap tolerance: base and ~3 remain, ~2 is gone
poll_not_exists(&dedup1, "X_Y~1.luau after deletion"); poll_not_exists(&dedup1, "X_Y~2.luau after deletion");
// Also verify the ~1 meta is gone // Also verify the ~2 meta is gone
poll_not_exists(&dedup1_meta, "X_Y~1.meta.json5 after deletion"); poll_not_exists(&dedup1_meta, "X_Y~2.meta.json5 after deletion");
thread::sleep(Duration::from_millis(300)); thread::sleep(Duration::from_millis(300));
assert_file_exists(&base, "X_Y.luau should remain"); assert_file_exists(&base, "X_Y.luau should remain");
assert_file_exists(&dedup2, "X_Y~2.luau should remain (gap tolerated)"); assert_file_exists(&dedup2, "X_Y~3.luau should remain (gap tolerated)");
}); });
} }
@ -7738,7 +7738,7 @@ fn add_same_named_folders_deduplicates() {
let src = session.path().join("src"); let src = session.path().join("src");
let data_dir = src.join("Data"); let data_dir = src.join("Data");
let data_dedup = src.join("Data~1"); let data_dedup = src.join("Data~2");
// Both directories should exist // Both directories should exist
assert!( assert!(
@ -7748,7 +7748,7 @@ fn add_same_named_folders_deduplicates() {
); );
assert!( assert!(
data_dedup.is_dir(), data_dedup.is_dir(),
"Data~1/ directory should exist at {}", "Data~2/ directory should exist at {}",
data_dedup.display() data_dedup.display()
); );
@ -7756,7 +7756,7 @@ fn add_same_named_folders_deduplicates() {
let dedup_meta = data_dedup.join("init.meta.json5"); let dedup_meta = data_dedup.join("init.meta.json5");
assert!( assert!(
dedup_meta.exists(), dedup_meta.exists(),
"Data~1/init.meta.json5 should exist for name override" "Data~2/init.meta.json5 should exist for name override"
); );
let meta_content = fs::read_to_string(&dedup_meta).unwrap(); let meta_content = fs::read_to_string(&dedup_meta).unwrap();
assert!( assert!(
@ -7907,7 +7907,7 @@ fn ref_through_deduped_instance() {
); );
// The Container's init.meta.json5 should have a Rojo_Ref_PrimaryPart // The Container's init.meta.json5 should have a Rojo_Ref_PrimaryPart
// attribute with a path that includes X_Y (base or ~1 suffix) // attribute with a path that includes X_Y (base or ~2 suffix)
let init_meta = container.join("init.meta.json5"); let init_meta = container.join("init.meta.json5");
poll_file_exists(&init_meta, "Container init.meta.json5 after ref set"); poll_file_exists(&init_meta, "Container init.meta.json5 after ref set");
thread::sleep(Duration::from_millis(300)); thread::sleep(Duration::from_millis(300));
@ -7998,15 +7998,15 @@ fn ref_path_updated_after_dedup_cleanup() {
let src = session.path().join("src"); let src = session.path().join("src");
let base = src.join("X_Y.luau"); let base = src.join("X_Y.luau");
let deduped = src.join("X_Y~1.luau"); let deduped = src.join("X_Y~2.luau");
poll_file_exists(&base, "X_Y.luau after add"); poll_file_exists(&base, "X_Y.luau after add");
poll_file_exists(&deduped, "X_Y~1.luau after add"); poll_file_exists(&deduped, "X_Y~2.luau after add");
thread::sleep(Duration::from_millis(500)); thread::sleep(Duration::from_millis(500));
// Step 2: Find the ~1 instance and point Pointer's child ObjectValue at it // Step 2: Find the ~2 instance and point Pointer's child ObjectValue at it
let rs_read = session.get_api_read(rs_id).unwrap(); let rs_read = session.get_api_read(rs_id).unwrap();
let dedup_meta_path = src.join("X_Y~1.meta.json5"); let dedup_meta_path = src.join("X_Y~2.meta.json5");
let dedup_meta_content = fs::read_to_string(&dedup_meta_path).unwrap(); let dedup_meta_content = fs::read_to_string(&dedup_meta_path).unwrap();
let target_name = if dedup_meta_content.contains("\"X/Y\"") { let target_name = if dedup_meta_content.contains("\"X/Y\"") {
"X/Y" "X/Y"
@ -8023,7 +8023,7 @@ fn ref_path_updated_after_dedup_cleanup() {
let pointer_read = session.get_api_read(pointer_id).unwrap(); let pointer_read = session.get_api_read(pointer_id).unwrap();
let (ptr_child_id, _) = find_by_name(&pointer_read.instances, "Ptr"); let (ptr_child_id, _) = find_by_name(&pointer_read.instances, "Ptr");
// Set ObjectValue.Value to the ~1 target // Set ObjectValue.Value to the ~2 target
let mut props = UstrMap::default(); let mut props = UstrMap::default();
props.insert(ustr("Value"), Some(Variant::Ref(target_id))); props.insert(ustr("Value"), Some(Variant::Ref(target_id)));
send_update( send_update(
@ -8038,32 +8038,32 @@ fn ref_path_updated_after_dedup_cleanup() {
}, },
); );
// Verify the ref was written with the ~1 path // Verify the ref was written with the ~2 path
let pointer_dir = src.join("Pointer"); let pointer_dir = src.join("Pointer");
let ptr_model_path = pointer_dir.join("Ptr.model.json5"); let ptr_model_path = pointer_dir.join("Ptr.model.json5");
poll_file_exists(&ptr_model_path, "Ptr.model.json5 after ref set"); poll_file_exists(&ptr_model_path, "Ptr.model.json5 after ref set");
thread::sleep(Duration::from_millis(300)); thread::sleep(Duration::from_millis(300));
let ref_content = fs::read_to_string(&ptr_model_path).unwrap(); let ref_content = fs::read_to_string(&ptr_model_path).unwrap();
assert!( assert!(
ref_content.contains("X_Y~1"), ref_content.contains("X_Y~2"),
"Before cleanup: ref should point to X_Y~1, got: {}", "Before cleanup: ref should point to X_Y~2, got: {}",
ref_content ref_content
); );
// Step 3: Delete the OTHER instance (not the target) to trigger // Step 3: Delete the OTHER instance (not the target) to trigger
// group-to-1 cleanup, which renames X_Y~1 → X_Y // group-to-1 cleanup, which renames X_Y~2 → X_Y
send_removal(&session, &info.session_id, vec![other_id]); send_removal(&session, &info.session_id, vec![other_id]);
// After cleanup: X_Y~1.luau renamed to X_Y.luau // After cleanup: X_Y~2.luau renamed to X_Y.luau
poll_not_exists(&deduped, "X_Y~1.luau after cleanup"); poll_not_exists(&deduped, "X_Y~2.luau after cleanup");
thread::sleep(Duration::from_millis(300)); thread::sleep(Duration::from_millis(300));
assert_file_exists(&base, "X_Y.luau after cleanup"); assert_file_exists(&base, "X_Y.luau after cleanup");
// The ref in Ptr.model.json5 should now point to the non-suffixed path // The ref in Ptr.model.json5 should now point to the non-suffixed path
let updated_ref = fs::read_to_string(&ptr_model_path).unwrap(); let updated_ref = fs::read_to_string(&ptr_model_path).unwrap();
assert!( assert!(
!updated_ref.contains("X_Y~1"), !updated_ref.contains("X_Y~2"),
"After cleanup: ref should NOT contain X_Y~1 (old path), got: {}", "After cleanup: ref should NOT contain X_Y~2 (old path), got: {}",
updated_ref updated_ref
); );
assert!( assert!(