diff --git a/.cursor/commands/ci.md b/.cursor/commands/ci.md index 62e238ee..b1405617 100644 --- a/.cursor/commands/ci.md +++ b/.cursor/commands/ci.md @@ -1,185 +1,50 @@ # /ci - Complete CI Pipeline -Run the complete CI pipeline: auto-fix all formatting, run all linters, build everything, and run ALL tests. +Run the complete CI pipeline by executing the appropriate platform script. ## Instructions -- If the user's message contains **"rbx-dom"**, run **both** the Rojo and rbx-dom sections below. -- Otherwise, **skip the entire rbx-dom section** (steps 9–14) to save time. +### 1. Determine rbx-dom Flag -Execute ALL applicable steps in order. Fix issues automatically where possible. +- By default, run **without** the skip flag (full suite including rbx-dom). +- If the user's message asks to **skip rbx-dom**, pass the skip flag (`-NoRbxDom` / `--no-rbx-dom`). ---- +### 2. Run the CI Script -## Rojo - -### 1. Auto-fix Lua Formatting (Stylua) +**Windows (PowerShell):** ```powershell -stylua plugin/src +.\scripts\ci.ps1 # with rbx-dom +.\scripts\ci.ps1 -NoRbxDom # without rbx-dom ``` -### 2. Auto-fix Rust Formatting -```powershell -cargo fmt +**macOS/Linux (Bash):** +```bash +bash scripts/ci.sh # with rbx-dom +bash scripts/ci.sh --no-rbx-dom # without rbx-dom ``` -### 3. Auto-fix TypeScript Formatting (Prettier) for vscode-rojo -```powershell -cd vscode-rojo -npx prettier --write "src/**/*.ts" -cd .. -``` +**Monitor the script output as it runs.** After each step completes, check its result. If a step failed, kill the script and switch to manual fixing (step 3) — don't let it continue to the next step. -### 4. Lua Static Analysis (Selene) - MUST PASS WITH ZERO ISSUES -```powershell -selene plugin/src -``` +### 3. Fix Failures Between Steps -**CRITICAL: Selene must exit with code 0 (zero errors AND zero warnings).** Warnings count as failures. +When a step fails, stop the script and fix the issue before continuing. Use targeted commands to speed up the feedback loop: -If Selene reports ANY errors or warnings: -1. **FIX THEM IMMEDIATELY** before proceeding - do not just report them -2. Common fixes: - - `unused_variable`: Replace with `_` (e.g., `for _ = 1, 10 do`) or remove the variable entirely - - `unused_variable` for imports: Remove the unused require/import line -3. Re-run Selene after fixes to confirm exit code 0 -4. Only proceed once Selene shows: `Results: 0 errors, 0 warnings, 0 parse errors` +1. **Formatting/linting failures** — auto-fix with the relevant formatter (`cargo fmt`, `stylua`), then verify with the specific check command only (e.g. `cargo fmt --check`). -### 5. Rust Linting (Clippy) - Auto-fix where possible -```powershell -cargo clippy --fix --allow-dirty --allow-staged -``` -Then verify with: -```powershell -cargo clippy --all-targets --all-features -``` -If there are remaining warnings/errors, fix them before proceeding. +2. **Test failures — investigate the implementation, not just the tests.** For every failing test: + - Read the failing test to understand what behavior it asserts. + - Read the implementation code the test exercises. + - Determine the root cause: + - **Implementation regression/bug:** The feature is incomplete or broken. **Fix the implementation** so the test passes. Do not weaken or delete the test. + - **Outdated test fixture:** The test's snapshot or fixture data is stale because of an intentional change. Update the fixture with `cargo insta review`/`cargo insta accept`, or regenerate the fixture data. Confirm the new fixture reflects correct behavior. + - **Incomplete feature:** A new feature was added but doesn't fully handle all cases the tests cover. **Complete the feature implementation** rather than trimming the tests to match a partial implementation. + - **Never** delete or gut a test just to make CI green. Tests exist for a reason — the code must satisfy them, not the other way around. + - **Re-run only the failing test(s)** to verify fixes quickly (e.g. `cargo test `, `cargo test -p `). Do not re-run the entire suite until you believe all issues are resolved. -### 6. Build Everything -```powershell -cargo build --locked --all-targets --all-features -``` +3. **After all known issues are fixed**, re-run the full CI script from step 2 to catch any remaining failures. -### 7. Run ALL Rust Tests -```powershell -cargo test --locked --all-targets --all-features -``` +4. Repeat until the script exits with `Overall: PASS`. -### 8. Run Roblox Plugin Tests +### 4. Report -**Do NOT use the scripts (`unit-test-plugin.ps1` / `unit-test-plugin.sh`).** They reference a system-installed binary which may not exist. Always use the freshly built binary from step 6. - -```powershell -.\target\debug\atlas.exe build plugin/test-place.project.json -o TestPlace.rbxl -run-in-roblox --script plugin/run-tests.server.lua --place TestPlace.rbxl -``` - ---- - -## rbx-dom (submodule) - -All rbx-dom steps run inside the `rbx-dom/` directory. - -### 9. Auto-fix Lua Formatting (Stylua) for rbx_dom_lua -```powershell -cd rbx-dom -stylua rbx_dom_lua/src -cd .. -``` - -### 10. Auto-fix Rust Formatting -```powershell -cd rbx-dom -cargo fmt -cd .. -``` - -### 11. Lua Static Analysis (Selene) for rbx_dom_lua - MUST PASS WITH ZERO ISSUES -```powershell -cd rbx-dom -selene rbx_dom_lua/src -cd .. -``` - -Same rules as step 4: exit code 0 required, fix all errors and warnings before proceeding. - -### 12. Rust Linting (Clippy) - Auto-fix where possible -```powershell -cd rbx-dom -cargo clippy --fix --allow-dirty --allow-staged -``` -Then verify with: -```powershell -cd rbx-dom -cargo clippy --all-targets --all-features -- -D warnings -cd .. -``` -If there are remaining warnings/errors, fix them before proceeding. - -### 13. Build rbx-dom -```powershell -cd rbx-dom -cargo build --verbose -cargo build --all-features --verbose -cd .. -``` - -### 14. Run ALL rbx-dom Rust Tests -```powershell -cd rbx-dom -cargo test --verbose -cargo test --all-features --verbose -cd .. -``` - ---- - -## Reporting - -After ALL steps complete, provide a summary: - -``` -=== CI COMPLETE === - ---- Rojo --- - -Formatting: - - Stylua: [fixed X files / no changes needed] - - Rustfmt: [fixed X files / no changes needed] - - Prettier (vscode-rojo): [fixed X files / no changes needed] - -Linting: - - Selene: PASS (0 errors, 0 warnings) / FAIL (X errors, Y warnings) - - Clippy: PASS / FAIL (X warnings) - -Build: PASS / FAIL - -Tests: - - Rust: X passed, Y failed - - Plugin: X passed, Y failed - ---- rbx-dom (only if included) --- - -Formatting: - - Stylua (rbx_dom_lua): [fixed X files / no changes needed] - - Rustfmt: [fixed X files / no changes needed] - -Linting: - - Selene (rbx_dom_lua): PASS (0 errors, 0 warnings) / FAIL (X errors, Y warnings) - - Clippy: PASS / FAIL (X warnings) - -Build: PASS / FAIL -Build (all features): PASS / FAIL - -Tests: - - Rust: X passed, Y failed - - Rust (all features): X passed, Y failed - ---- Overall: PASS / FAIL --- -``` - -**IMPORTANT:** -- Selene PASS requires exit code 0 (0 errors AND 0 warnings). Any warnings = FAIL. -- If any step fails, continue running the remaining steps to get a complete picture, then report all failures at the end. -- Do not report "Overall: PASS" unless ALL linters pass with zero issues across all sections that were run. -- If rbx-dom was skipped, omit its section from the report entirely. +Once CI passes clean, provide a brief summary of what was fixed (if anything) and confirm the final result. diff --git a/scripts/ci.ps1 b/scripts/ci.ps1 new file mode 100644 index 00000000..8d4d0220 --- /dev/null +++ b/scripts/ci.ps1 @@ -0,0 +1,193 @@ +# Complete CI Pipeline +# Usage: .\scripts\ci.ps1 [-NoRbxDom] + +param( + [switch]$NoRbxDom +) + +$RbxDom = -not $NoRbxDom + +$ErrorActionPreference = "Continue" +$failures = @() + +function Write-Step($number, $name) { + Write-Host "" + Write-Host "=== Step ${number}: $name ===" -ForegroundColor Cyan +} + +function Record-Result($step, $exitCode) { + if ($exitCode -ne 0) { + $script:failures += $step + Write-Host "FAIL" -ForegroundColor Red + } else { + Write-Host "PASS" -ForegroundColor Green + } +} + +# ─── Rojo ──────────────────────────────────────────────────────────────────── + +Write-Step 1 "Auto-fix Lua Formatting (Stylua)" +stylua plugin/src +$styluaRojo = $LASTEXITCODE +Record-Result "Stylua (Rojo)" $styluaRojo + +Write-Step 2 "Auto-fix Rust Formatting" +cargo fmt +$rustfmt = $LASTEXITCODE +Record-Result "Rustfmt" $rustfmt + +Write-Step 3 "Auto-fix TypeScript Formatting (Prettier)" +Push-Location vscode-rojo +npx prettier --write "src/**/*.ts" +$prettier = $LASTEXITCODE +Pop-Location +Record-Result "Prettier" $prettier + +Write-Step 4 "Compile TypeScript Extension (vscode-rojo)" +Push-Location vscode-rojo +npm run compile +$tsBuild = $LASTEXITCODE +Pop-Location +Record-Result "TS Compile (vscode-rojo)" $tsBuild + +Write-Step 5 "Lua Static Analysis (Selene)" +selene plugin/src +$selene = $LASTEXITCODE +Record-Result "Selene" $selene + +Write-Step 6 "Rust Linting (Clippy) - Auto-fix" +cargo clippy --fix --allow-dirty --allow-staged 2>&1 | Out-Null +Write-Host "Verifying..." -ForegroundColor Yellow +cargo clippy --all-targets --all-features 2>&1 +$clippy = $LASTEXITCODE +Record-Result "Clippy" $clippy + +Write-Step 7 "Build Everything" +cargo build --locked --all-targets --all-features +$build = $LASTEXITCODE +Record-Result "Build" $build + +Write-Step 8 "Run ALL Rust Tests" +$testOutput = cargo test --locked --all-targets --all-features 2>&1 +$rustTests = $LASTEXITCODE +$testOutput | Write-Host +Record-Result "Rust Tests" $rustTests + +Write-Step 9 "Run Roblox Plugin Tests" +.\target\debug\atlas.exe build plugin/test-place.project.json -o TestPlace.rbxl +if ($LASTEXITCODE -eq 0) { + run-in-roblox --script plugin/run-tests.server.lua --place TestPlace.rbxl + $pluginTests = $LASTEXITCODE +} else { + Write-Host "Skipped: build failed" -ForegroundColor Yellow + $pluginTests = 1 +} +Record-Result "Plugin Tests" $pluginTests + +# ─── rbx-dom ───────────────────────────────────────────────────────────────── + +if ($RbxDom) { + Write-Step 10 "Auto-fix Lua Formatting (Stylua) for rbx_dom_lua" + Push-Location rbx-dom + stylua rbx_dom_lua/src + $styluaRbxDom = $LASTEXITCODE + Pop-Location + Record-Result "Stylua (rbx_dom_lua)" $styluaRbxDom + + Write-Step 11 "Auto-fix Rust Formatting (rbx-dom)" + Push-Location rbx-dom + cargo fmt + $rustfmtRbxDom = $LASTEXITCODE + Pop-Location + Record-Result "Rustfmt (rbx-dom)" $rustfmtRbxDom + + Write-Step 12 "Lua Static Analysis (Selene) for rbx_dom_lua" + Push-Location rbx-dom + selene rbx_dom_lua/src + $seleneRbxDom = $LASTEXITCODE + Pop-Location + Record-Result "Selene (rbx_dom_lua)" $seleneRbxDom + + Write-Step 13 "Rust Linting (Clippy) for rbx-dom - Auto-fix" + Push-Location rbx-dom + cargo clippy --fix --allow-dirty --allow-staged 2>&1 | Out-Null + Write-Host "Verifying..." -ForegroundColor Yellow + cargo clippy --all-targets --all-features -- -D warnings 2>&1 + $clippyRbxDom = $LASTEXITCODE + Pop-Location + Record-Result "Clippy (rbx-dom)" $clippyRbxDom + + Write-Step 14 "Build rbx-dom" + Push-Location rbx-dom + cargo build --verbose + $buildRbxDom = $LASTEXITCODE + cargo build --all-features --verbose + $buildRbxDomAll = $LASTEXITCODE + Pop-Location + Record-Result "Build (rbx-dom)" $buildRbxDom + Record-Result "Build (rbx-dom all-features)" $buildRbxDomAll + + Write-Step 15 "Run ALL rbx-dom Rust Tests" + Push-Location rbx-dom + cargo test --verbose + $testsRbxDom = $LASTEXITCODE + cargo test --all-features --verbose + $testsRbxDomAll = $LASTEXITCODE + Pop-Location + Record-Result "Tests (rbx-dom)" $testsRbxDom + Record-Result "Tests (rbx-dom all-features)" $testsRbxDomAll +} + +# ─── Report ────────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "=== CI COMPLETE ===" -ForegroundColor Magenta +Write-Host "" +Write-Host "--- Rojo ---" -ForegroundColor White +Write-Host "" +Write-Host "Formatting:" +Write-Host " - Stylua: $(if ($styluaRojo -eq 0) {'PASS'} else {'FAIL'})" +Write-Host " - Rustfmt: $(if ($rustfmt -eq 0) {'PASS'} else {'FAIL'})" +Write-Host " - Prettier (vscode-rojo): $(if ($prettier -eq 0) {'PASS'} else {'FAIL'})" +Write-Host "" +Write-Host "TS Compile (vscode-rojo): $(if ($tsBuild -eq 0) {'PASS'} else {'FAIL'})" +Write-Host "" +Write-Host "Linting:" +Write-Host " - Selene: $(if ($selene -eq 0) {'PASS'} else {'FAIL'})" +Write-Host " - Clippy: $(if ($clippy -eq 0) {'PASS'} else {'FAIL'})" +Write-Host "" +Write-Host "Build: $(if ($build -eq 0) {'PASS'} else {'FAIL'})" +Write-Host "" +Write-Host "Tests:" +Write-Host " - Rust: $(if ($rustTests -eq 0) {'PASS'} else {'FAIL'})" +Write-Host " - Plugin: $(if ($pluginTests -eq 0) {'PASS'} else {'FAIL'})" + +if ($RbxDom) { + Write-Host "" + Write-Host "--- rbx-dom ---" -ForegroundColor White + Write-Host "" + Write-Host "Formatting:" + Write-Host " - Stylua (rbx_dom_lua): $(if ($styluaRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host " - Rustfmt: $(if ($rustfmtRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host "" + Write-Host "Linting:" + Write-Host " - Selene (rbx_dom_lua): $(if ($seleneRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host " - Clippy: $(if ($clippyRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host "" + Write-Host "Build: $(if ($buildRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host "Build (all features): $(if ($buildRbxDomAll -eq 0) {'PASS'} else {'FAIL'})" + Write-Host "" + Write-Host "Tests:" + Write-Host " - Rust: $(if ($testsRbxDom -eq 0) {'PASS'} else {'FAIL'})" + Write-Host " - Rust (all features): $(if ($testsRbxDomAll -eq 0) {'PASS'} else {'FAIL'})" +} + +Write-Host "" +if ($failures.Count -eq 0) { + Write-Host "--- Overall: PASS ---" -ForegroundColor Green + exit 0 +} else { + Write-Host "--- Overall: FAIL ---" -ForegroundColor Red + Write-Host "Failed steps: $($failures -join ', ')" -ForegroundColor Red + exit 1 +} diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100644 index 00000000..e3a2cf12 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# Complete CI Pipeline +# Usage: bash scripts/ci.sh [--no-rbx-dom] + +set -o pipefail + +RBX_DOM=true +for arg in "$@"; do + case "$arg" in + --no-rbx-dom) RBX_DOM=false ;; + esac +done + +failures=() + +step() { + echo "" + echo "=== Step $1: $2 ===" +} + +record() { + if [ "$2" -ne 0 ]; then + failures+=("$1") + echo "FAIL" + else + echo "PASS" + fi +} + +# ─── Rojo ──────────────────────────────────────────────────────────────────── + +step 1 "Auto-fix Lua Formatting (Stylua)" +stylua plugin/src +stylua_rojo=$? +record "Stylua (Rojo)" $stylua_rojo + +step 2 "Auto-fix Rust Formatting" +cargo fmt +rustfmt_exit=$? +record "Rustfmt" $rustfmt_exit + +step 3 "Auto-fix TypeScript Formatting (Prettier)" +(cd vscode-rojo && npx prettier --write "src/**/*.ts") +prettier_exit=$? +record "Prettier" $prettier_exit + +step 4 "Compile TypeScript Extension (vscode-rojo)" +(cd vscode-rojo && npm run compile) +ts_build=$? +record "TS Compile (vscode-rojo)" $ts_build + +step 5 "Lua Static Analysis (Selene)" +selene plugin/src +selene_exit=$? +record "Selene" $selene_exit + +step 6 "Rust Linting (Clippy) - Auto-fix" +cargo clippy --fix --allow-dirty --allow-staged >/dev/null 2>&1 +echo "Verifying..." +cargo clippy --all-targets --all-features 2>&1 +clippy_exit=$? +record "Clippy" $clippy_exit + +step 7 "Build Everything" +cargo build --locked --all-targets --all-features +build_exit=$? +record "Build" $build_exit + +step 8 "Run ALL Rust Tests" +cargo test --locked --all-targets --all-features 2>&1 +rust_tests=$? +record "Rust Tests" $rust_tests + +step 9 "Run Roblox Plugin Tests" +./target/debug/atlas build plugin/test-place.project.json -o TestPlace.rbxl +if [ $? -eq 0 ]; then + run-in-roblox --script plugin/run-tests.server.lua --place TestPlace.rbxl + plugin_tests=$? +else + echo "Skipped: build failed" + plugin_tests=1 +fi +record "Plugin Tests" $plugin_tests + +# ─── rbx-dom ───────────────────────────────────────────────────────────────── + +if [ "$RBX_DOM" = true ]; then + step 10 "Auto-fix Lua Formatting (Stylua) for rbx_dom_lua" + (cd rbx-dom && stylua rbx_dom_lua/src) + stylua_rbxdom=$? + record "Stylua (rbx_dom_lua)" $stylua_rbxdom + + step 11 "Auto-fix Rust Formatting (rbx-dom)" + (cd rbx-dom && cargo fmt) + rustfmt_rbxdom=$? + record "Rustfmt (rbx-dom)" $rustfmt_rbxdom + + step 12 "Lua Static Analysis (Selene) for rbx_dom_lua" + (cd rbx-dom && selene rbx_dom_lua/src) + selene_rbxdom=$? + record "Selene (rbx_dom_lua)" $selene_rbxdom + + step 13 "Rust Linting (Clippy) for rbx-dom - Auto-fix" + (cd rbx-dom && cargo clippy --fix --allow-dirty --allow-staged >/dev/null 2>&1) + echo "Verifying..." + (cd rbx-dom && cargo clippy --all-targets --all-features -- -D warnings 2>&1) + clippy_rbxdom=$? + record "Clippy (rbx-dom)" $clippy_rbxdom + + step 14 "Build rbx-dom" + (cd rbx-dom && cargo build --verbose) + build_rbxdom=$? + (cd rbx-dom && cargo build --all-features --verbose) + build_rbxdom_all=$? + record "Build (rbx-dom)" $build_rbxdom + record "Build (rbx-dom all-features)" $build_rbxdom_all + + step 15 "Run ALL rbx-dom Rust Tests" + (cd rbx-dom && cargo test --verbose) + tests_rbxdom=$? + (cd rbx-dom && cargo test --all-features --verbose) + tests_rbxdom_all=$? + record "Tests (rbx-dom)" $tests_rbxdom + record "Tests (rbx-dom all-features)" $tests_rbxdom_all +fi + +# ─── Report ────────────────────────────────────────────────────────────────── + +pass_or_fail() { [ "$1" -eq 0 ] && echo "PASS" || echo "FAIL"; } + +echo "" +echo "=== CI COMPLETE ===" +echo "" +echo "--- Rojo ---" +echo "" +echo "Formatting:" +echo " - Stylua: $(pass_or_fail $stylua_rojo)" +echo " - Rustfmt: $(pass_or_fail $rustfmt_exit)" +echo " - Prettier (vscode-rojo): $(pass_or_fail $prettier_exit)" +echo "" +echo "TS Compile (vscode-rojo): $(pass_or_fail $ts_build)" +echo "" +echo "Linting:" +echo " - Selene: $(pass_or_fail $selene_exit)" +echo " - Clippy: $(pass_or_fail $clippy_exit)" +echo "" +echo "Build: $(pass_or_fail $build_exit)" +echo "" +echo "Tests:" +echo " - Rust: $(pass_or_fail $rust_tests)" +echo " - Plugin: $(pass_or_fail $plugin_tests)" + +if [ "$RBX_DOM" = true ]; then + echo "" + echo "--- rbx-dom ---" + echo "" + echo "Formatting:" + echo " - Stylua (rbx_dom_lua): $(pass_or_fail $stylua_rbxdom)" + echo " - Rustfmt: $(pass_or_fail $rustfmt_rbxdom)" + echo "" + echo "Linting:" + echo " - Selene (rbx_dom_lua): $(pass_or_fail $selene_rbxdom)" + echo " - Clippy: $(pass_or_fail $clippy_rbxdom)" + echo "" + echo "Build: $(pass_or_fail $build_rbxdom)" + echo "Build (all features): $(pass_or_fail $build_rbxdom_all)" + echo "" + echo "Tests:" + echo " - Rust: $(pass_or_fail $tests_rbxdom)" + echo " - Rust (all features): $(pass_or_fail $tests_rbxdom_all)" +fi + +echo "" +if [ ${#failures[@]} -eq 0 ]; then + echo "--- Overall: PASS ---" + exit 0 +else + echo "--- Overall: FAIL ---" + echo "Failed steps: $(IFS=', '; echo "${failures[*]}")" + exit 1 +fi diff --git a/src/change_processor.rs b/src/change_processor.rs index 5917e4ea..af3107ba 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -296,11 +296,7 @@ impl JobThreadContext { match crate::json::to_vec_pretty_sorted(&serde_json::Value::Object(obj)) { Ok(content) => { if let Err(err) = fs::write(meta_path, &content) { - log::error!( - "Failed to write meta file {}: {}", - meta_path.display(), - err - ); + log::error!("Failed to write meta file {}: {}", meta_path.display(), err); } } Err(err) => { @@ -773,8 +769,8 @@ impl JobThreadContext { self.unsuppress_path(&new_meta); } } - let init_meta = new_dir_path - .join("init.meta.json5"); + let init_meta = + new_dir_path.join("init.meta.json5"); if slugified_new_name != *new_name { // New name needs slugification — write name field self.upsert_meta_name_field( diff --git a/src/snapshot_middleware/csv.rs b/src/snapshot_middleware/csv.rs index 385967b3..e94ab12a 100644 --- a/src/snapshot_middleware/csv.rs +++ b/src/snapshot_middleware/csv.rs @@ -109,11 +109,20 @@ pub fn syncback_csv<'sync>( if !meta.is_empty() { let parent = snapshot.path.parent_err()?; - let instance_name = &new_inst.name; - let meta_name = if name_needs_slugify(instance_name) { - slugify_name(instance_name) + let meta_name = snapshot + .path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let meta_name = if meta_name.is_empty() { + let instance_name = &new_inst.name; + if name_needs_slugify(instance_name) { + slugify_name(instance_name) + } else { + instance_name.clone() + } } else { - instance_name.clone() + meta_name.to_string() }; fs_snapshot.add_file( parent.join(format!("{}.meta.json5", meta_name)), diff --git a/src/snapshot_middleware/dir.rs b/src/snapshot_middleware/dir.rs index 479fc155..8794640d 100644 --- a/src/snapshot_middleware/dir.rs +++ b/src/snapshot_middleware/dir.rs @@ -186,19 +186,22 @@ pub fn syncback_dir_no_meta<'sync>( old_child_map.insert(inst.name(), inst); } - // Pre-seed taken_names from old children's slugified instance names. - // This ensures new-only children that happen to slugify to the same - // bare slug as an existing sibling will be deduplicated (e.g., new - // "A/B" → slug "A_B" won't collide with existing "A_B"). - // Matches the approach used in api.rs (lines 902-920). - for (_, inst) in &old_child_map { - let name = inst.name(); - let slug = if name_needs_slugify(name) { - slugify_name(name).to_lowercase() - } else { - name.to_lowercase() - }; - taken_names.insert(slug); + // Pre-seed taken_names from old children's slugified instance names + // so that new-only children correctly dedup against existing siblings + // (e.g., new "A/B" → slug "A_B" won't collide with existing "A_B"). + // Only in incremental mode: in clean mode, old_ref is forced to None + // for all children, so every child is treated as new and pre-seeding + // would cause false collisions (spurious ~1 suffixes). + if snapshot.data.is_incremental() { + for inst in old_child_map.values() { + let name = inst.name(); + let slug = if name_needs_slugify(name) { + slugify_name(name).to_lowercase() + } else { + name.to_lowercase() + }; + taken_names.insert(slug); + } } for new_child_ref in new_inst.children() { diff --git a/src/snapshot_middleware/project.rs b/src/snapshot_middleware/project.rs index f5f4a0c8..90cf2050 100644 --- a/src/snapshot_middleware/project.rs +++ b/src/snapshot_middleware/project.rs @@ -20,7 +20,10 @@ use crate::{ PathIgnoreRule, SyncRule, }, snapshot_middleware::Middleware, - syncback::{filter_properties, inst_path, FsSnapshot, SyncbackReturn, SyncbackSnapshot}, + syncback::{ + filter_properties, inst_path, name_needs_slugify, slugify_name, FsSnapshot, SyncbackReturn, + SyncbackSnapshot, + }, variant_eq::variant_eq, RojoRef, }; @@ -655,6 +658,27 @@ pub fn syncback_project<'sync>( std::collections::HashSet, > = std::collections::HashMap::new(); + // Pre-seed taken_names from old children's slugified instance names + // so that new-only children correctly dedup against existing siblings + // (e.g., new "utils" won't collide with existing "Utils.luau"). + // Only in incremental mode: in clean mode, old_ref is forced to None + // for all children, so every child is treated as new and pre-seeding + // would cause false collisions (spurious ~1 suffixes). + if snapshot.data.is_incremental() { + if let Some(parent_path) = ref_to_path_map.get(&new_inst.referent()) { + let taken = taken_names_per_dir.entry(parent_path.clone()).or_default(); + for old_child in old_child_map.values() { + let name = old_child.name(); + let slug = if name_needs_slugify(name) { + slugify_name(name).to_lowercase() + } else { + name.to_lowercase() + }; + taken.insert(slug); + } + } + } + for (name, new_child) in new_child_map.drain() { // Skip instances of ignored classes if snapshot.should_ignore_class(&new_child.class) { diff --git a/src/snapshot_middleware/txt.rs b/src/snapshot_middleware/txt.rs index 82e00c03..17e65145 100644 --- a/src/snapshot_middleware/txt.rs +++ b/src/snapshot_middleware/txt.rs @@ -58,11 +58,20 @@ pub fn syncback_txt<'sync>( if !meta.is_empty() { let parent = snapshot.path.parent_err()?; - let instance_name = &new_inst.name; - let meta_name = if name_needs_slugify(instance_name) { - slugify_name(instance_name) + let meta_name = snapshot + .path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + let meta_name = if meta_name.is_empty() { + let instance_name = &new_inst.name; + if name_needs_slugify(instance_name) { + slugify_name(instance_name) + } else { + instance_name.clone() + } } else { - instance_name.clone() + meta_name.to_string() }; fs_snapshot.add_file( parent.join(format!("{}.meta.json5", meta_name)),