fix(management): case-variant instance override names are rejected with a did-you-mean hint (ScadaBridge#105)

Verified the ManagementActor bulk attribute override and single/bulk native alarm source
override paths match names ordinally and already reject a case-variant name (nothing stored);
the error now names the single case-insensitive match. Tests pin the rejection.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 16:23:37 -05:00
parent 0c2e852a62
commit a2c3a2d2b4
2 changed files with 71 additions and 4 deletions
@@ -1140,6 +1140,18 @@ public class ManagementActor : ReceiveActor
: throw new ManagementCommandException(result.Error);
}
/// <summary>
/// ScadaBridge#105: override names are matched exactly (the flattener is ordinal), so a name
/// that differs from a member only by case is rejected, never stored. When exactly one
/// member matches case-insensitively, name it in the error so the caller can correct it;
/// an ambiguous match gets no hint.
/// </summary>
internal static string CaseMismatchHint(string name, IEnumerable<string> candidates)
{
var matches = candidates.Where(c => string.Equals(c, name, StringComparison.OrdinalIgnoreCase)).Distinct(StringComparer.Ordinal).Take(2).ToList();
return matches.Count == 1 ? $" (names are case-sensitive; did you mean '{matches[0]}'?)" : string.Empty;
}
private static async Task<object?> HandleSetInstanceOverrides(IServiceProvider sp, SetInstanceOverridesCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
@@ -1163,8 +1175,8 @@ public class ManagementActor : ReceiveActor
{
if (!attrsByName.TryGetValue(attrName, out var templateAttr))
throw new ManagementCommandException(
$"Attribute '{attrName}' does not exist in template {instance.TemplateId}. " +
"No overrides were applied.");
$"Attribute '{attrName}' does not exist in template {instance.TemplateId}" +
CaseMismatchHint(attrName, attrsByName.Keys) + ". No overrides were applied.");
if (templateAttr.IsLocked)
throw new ManagementCommandException(
$"Attribute '{attrName}' is locked and cannot be overridden. No overrides were applied.");
@@ -1288,7 +1300,9 @@ public class ManagementActor : ReceiveActor
.FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.SourceCanonicalName, StringComparison.Ordinal));
if (resolvedSource == null)
throw new ManagementCommandException(
$"Native alarm source '{cmd.SourceCanonicalName}' does not resolve for this instance and cannot be overridden.");
$"Native alarm source '{cmd.SourceCanonicalName}' does not resolve for this instance and cannot be overridden" +
CaseMismatchHint(cmd.SourceCanonicalName,
flattenResult.Value.Configuration.NativeAlarmSources.Select(x => x.CanonicalName)) + ".");
if (resolvedSource.IsLocked)
throw new ManagementCommandException(
$"Native alarm source '{cmd.SourceCanonicalName}' is locked at the template level and cannot be overridden.");
@@ -1349,7 +1363,8 @@ public class ManagementActor : ReceiveActor
if (!resolvedByName.TryGetValue(entry.SourceCanonicalName, out var resolvedSource))
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' does not resolve for this instance " +
"and cannot be overridden. No overrides were applied.");
"and cannot be overridden" + CaseMismatchHint(entry.SourceCanonicalName, resolvedByName.Keys) +
". No overrides were applied.");
if (resolvedSource.IsLocked)
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' is locked at the template level and " +