From eeb2726c05deb73790c0444c4651e7ab94ceb425 Mon Sep 17 00:00:00 2001 From: VirSanctus <25885060+VirSanctus@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:04:29 +0300 Subject: [PATCH] Allow renaming a resource to a different case of its own name (#328) The conflict check matched the resource being renamed, so a case-only rename such as Srv01 -> srv01 failed with "already exists". Only a different resource with the new name is now a conflict, so renaming onto another resource's case variant is still refused, including in a config that already holds both variants. Renaming a resource to exactly its current name now succeeds without writing anything, instead of failing with "already exists". --- .../UseCases/RenameResourceUseCase.cs | 10 +- .../ConnectionTests/RenameResourceTests.cs | 106 ++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/RackPeek.Domain/UseCases/RenameResourceUseCase.cs b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs index 17b636b..1cbb462 100644 --- a/RackPeek.Domain/UseCases/RenameResourceUseCase.cs +++ b/RackPeek.Domain/UseCases/RenameResourceUseCase.cs @@ -18,14 +18,20 @@ public async Task ExecuteAsync(string originalName, string newName) { newName = Normalize.HardwareName(newName); ThrowIfInvalid.ResourceName(newName); - Resource? existingResource = await repo.GetByNameAsync(newName); + Resource? original = await repo.GetByNameAsync(originalName); + + IReadOnlyList resources = await repo.GetAllOfTypeAsync(); + Resource? existingResource = resources.FirstOrDefault(r => + !ReferenceEquals(r, original) && r.Name.Equals(newName, StringComparison.OrdinalIgnoreCase)); if (existingResource != null) throw new ConflictException($"{existingResource.Kind} resource '{newName}' already exists."); - Resource? original = await repo.GetByNameAsync(originalName); if (original == null) throw new NotFoundException($"Resource '{originalName}' not found."); + if (original.Name == newName) + return; + original.Name = newName; await repo.UpdateAsync(original); diff --git a/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs b/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs index 123b308..d92cca4 100644 --- a/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs +++ b/Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs @@ -208,4 +208,110 @@ await ExecuteAsync("connections", "add", Assert.Contains("srv-prod-app-01", yaml); Assert.Contains("app-backend-link", yaml); } + + [Fact] + public async Task rename_that_only_changes_case_updates_the_name() { + await ExecuteAsync("servers", "add", "CaseOnly21"); + + (var output, var yaml) = await ExecuteAsync("servers", "rename", "CaseOnly21", "caseonly21"); + + Assert.Contains("Server 'CaseOnly21' renamed to 'caseonly21'.", output); + Assert.Contains("name: caseonly21", yaml); + Assert.DoesNotContain("name: CaseOnly21", yaml); + } + + [Fact] + public async Task rename_that_only_changes_case_updates_runs_on_and_connections() { + await ExecuteAsync("servers", "add", "CaseOnly31"); + await ExecuteAsync("servers", "add", "CaseOnly32"); + + await ExecuteAsync("servers", "nic", "add", "CaseOnly31", + "--type", "RJ45", "--speed", "10", "--ports", "2"); + + await ExecuteAsync("servers", "nic", "add", "CaseOnly32", + "--type", "RJ45", "--speed", "10", "--ports", "2"); + + await ExecuteAsync("connections", "add", + "CaseOnly31", "0", "0", + "CaseOnly32", "0", "0", + "--label", "case-only-link"); + + await ExecuteAsync("systems", "add", "sys-case-only-31"); + await ExecuteAsync("systems", "set", "sys-case-only-31", "--runs-on", "CaseOnly31"); + + (var output, var yaml) = await ExecuteAsync("servers", "rename", "CaseOnly31", "CASEONLY31"); + + Assert.Contains("Server 'CaseOnly31' renamed to 'CASEONLY31'.", output); + Assert.Contains("name: CASEONLY31", yaml); + Assert.Contains("resource: CASEONLY31", yaml); + Assert.Contains("- CASEONLY31", yaml); + Assert.DoesNotContain("CaseOnly31", yaml); + } + + [Fact] + public async Task rename_onto_another_resources_case_variant_is_still_refused() { + await ExecuteAsync("servers", "add", "Clash41"); + await ExecuteAsync("servers", "add", "Clash42"); + await ExecuteAsync("systems", "add", "clash43"); + + (var sameKind, _) = await ExecuteAsync("servers", "rename", "Clash41", "CLASH42"); + (var otherKind, var yaml) = await ExecuteAsync("servers", "rename", "Clash41", "CLASH43"); + + Assert.Contains("Conflict: Server resource 'CLASH42' already exists.", sameKind); + Assert.Contains("Conflict: System resource 'CLASH43' already exists.", otherKind); + Assert.Contains("name: Clash41", yaml); + Assert.Contains("name: Clash42", yaml); + Assert.Contains("name: clash43", yaml); + Assert.DoesNotContain("CLASH4", yaml); + } + + [Fact] + public async Task rename_to_the_identical_name_leaves_the_file_unchanged() { + await ExecuteAsync("servers", "add", "Same51"); + await ExecuteAsync("servers", "add", "Same52"); + await ExecuteAsync("servers", "add", "Same53"); + + foreach (var s in new[] { "Same51", "Same52", "Same53" }) { + await ExecuteAsync("servers", "nic", "add", s, + "--type", "RJ45", "--speed", "10", "--ports", "2"); + } + + await ExecuteAsync("connections", "add", + "Same51", "0", "0", + "Same52", "0", "0", + "--label", "same-link"); + + await ExecuteAsync("connections", "add", + "Same52", "0", "1", + "Same53", "0", "0", + "--label", "other-link"); + + await ExecuteAsync("systems", "add", "sys-same-51"); + (_, var before) = await ExecuteAsync("systems", "set", "sys-same-51", "--runs-on", "same51"); + + (var output, var after) = await ExecuteAsync("servers", "rename", "Same51", "Same51"); + + Assert.Contains("Server 'Same51' renamed to 'Same51'.", output); + Assert.Equal(before, after); + } + + [Fact] + public async Task rename_is_refused_when_the_config_already_holds_a_case_variant() { + await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """ + version: 4 + resources: + - kind: Server + name: Dup61 + - kind: Server + name: dup61 + connections: [] + """); + + (var output, var yaml) = await ExecuteAsync("servers", "rename", "Dup61", "DUP61"); + + Assert.Contains("Conflict: Server resource 'DUP61' already exists.", output); + Assert.Contains("name: Dup61", yaml); + Assert.Contains("name: dup61", yaml); + Assert.DoesNotContain("DUP61", yaml); + } }