Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs
T
Joseph Doherty 38edfefc42 refactor(transport): single-source template child equality — diff and overwrite-sync can no longer drift
New TemplateChildEquality is the one place "has this template child changed?" is
decided, comparing the COMPLETE writable field set per entity (attributes now incl.
ElementDataType + LockedInDerived; alarms incl. the on-trigger script by name +
LockedInDerived; scripts incl. MinTimeBetweenRuns/ExecutionTimeoutSeconds +
LockedInDerived; native sources per T5). ArtifactDiff.CompareTemplate and all four
BundleImporter.SyncTemplate*Async predicates now route through it — closing the
class of bug where Preview reported Identical for an artifact an Overwrite would
mutate. The attribute sync passes a value-normalised DTO so List-value re-imports
stay idempotent; the alarm on-trigger change now surfaces in both diff and sync.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
2026-07-09 17:17:18 -04:00

727 lines
36 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// Compares an incoming bundle DTO against the existing entity in the target
/// database and produces a single <see cref="ImportPreviewItem"/> that
/// classifies the conflict and (for <c>Modified</c>) carries a coarse field
/// diff in JSON.
/// <para>
/// "Coarse" means: each persistent field is compared as a value; differing
/// non-code fields appear in <c>changes</c> with old/new values. Code / large-
/// text fields (script bodies, API-method scripts) instead carry a structured
/// per-line Myers diff: the <see cref="FieldChange.OldValue"/> /
/// <see cref="FieldChange.NewValue"/> keep a compact <c>&lt;N lines&gt;</c>
/// summary for fallback rendering, and a <see cref="FieldChange.LineDiff"/>
/// payload carries the hunk lines (+/-/context) plus add/remove totals and a
/// truncation flag. The diff is size-capped via <see cref="LineDiffer"/>'s
/// <c>maxLines</c> cap so <see cref="ImportPreviewItem.FieldDiffJson"/> stays
/// bounded.
/// </para>
/// <para>
/// Entity versions are not yet tracked on the POCOs, so the
/// <see cref="ImportPreviewItem.ExistingVersion"/> / <see cref="ImportPreviewItem.IncomingVersion"/>
/// fields are always <c>null</c> here. They are reserved for a future
/// optimistic-concurrency feature.
/// </para>
/// </summary>
public sealed class ArtifactDiff
{
private static readonly JsonSerializerOptions DiffJsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// ---- Templates ----
/// <summary>
/// Compares an incoming template against the existing template in the database.
/// </summary>
/// <param name="incoming">The incoming template from the bundle.</param>
/// <param name="existing">The existing template in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareTemplate(TemplateDto incoming, Template? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null)
{
return New("Template", incoming.Name);
}
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
AddIfDifferent(changes, "FolderName", FolderNameOf(existing), incoming.FolderName);
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing), incoming.BaseTemplateName);
// Bundles reference the alarm on-trigger script by name; resolve the
// persisted FK back to a name over this template's own scripts so the
// shared equality can compare it. See TemplateChildEquality.
var scriptNameById = TemplateChildEquality.ScriptNameResolver(existing.Scripts);
// Children: compare each child collection by name. We track which names
// were added, which were removed, and which existed on both sides but
// diverged in body. We use coarse value equality / line counts for
// scripts so the diff JSON stays under a few KB per item. Change
// detection is single-sourced through TemplateChildEquality so the diff
// can never disagree with what an Overwrite sync writes (#05-T6).
DiffChildren(
existing.Attributes,
incoming.Attributes,
e => e.Name,
i => i.Name,
TemplateChildEquality.AttributesEqual,
"Attributes",
changes);
DiffChildren(
existing.Alarms,
incoming.Alarms,
e => e.Name,
i => i.Name,
(e, i) => TemplateChildEquality.AlarmsEqual(e, i, scriptNameById),
"Alarms",
changes);
DiffScriptChildren(existing.Scripts, incoming.Scripts, changes);
// Compositions diff by InstanceName since ComposedTemplateId vs
// ComposedTemplateName aren't directly comparable. The bundle side
// already serializes to the name form, so the comparison reduces to
// (InstanceName, ComposedTemplateName) tuple equality.
DiffChildren(
existing.Compositions,
incoming.Compositions,
e => e.InstanceName,
i => i.InstanceName,
(e, i) => CompositionTargetNameOf(e) == i.ComposedTemplateName,
"Compositions",
changes);
DiffChildren(
existing.NativeAlarmSources,
incoming.NativeAlarmSources,
e => e.Name,
i => i.Name,
TemplateChildEquality.NativeAlarmSourcesEqual,
"NativeAlarmSources",
changes);
return BuildItem("Template", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming shared script against the existing shared script in the database.
/// </summary>
/// <param name="incoming">The incoming shared script from the bundle.</param>
/// <param name="existing">The existing shared script in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareSharedScript(SharedScriptDto incoming, SharedScript? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("SharedScript", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
AddCodeChangeIfDifferent(changes, "Code", existing.Code, incoming.Code);
return BuildItem("SharedScript", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming external system against the existing external system in the database.
/// </summary>
/// <param name="incoming">The incoming external system from the bundle.</param>
/// <param name="existing">The existing external system in the database, or null if new.</param>
/// <param name="existingMethods">The existing external system methods, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareExternalSystem(ExternalSystemDto incoming, ExternalSystemDefinition? existing, IReadOnlyList<ExternalSystemMethod>? existingMethods)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("ExternalSystem", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "BaseUrl", existing.EndpointUrl, incoming.BaseUrl);
AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);
// Secrets: presence-only comparison (we never echo the value in the diff).
var existingHasSecret = !string.IsNullOrEmpty(existing.AuthConfiguration);
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthConfiguration");
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange("Secrets.AuthConfiguration",
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
// Methods are name-keyed children.
var existingForCompare = existingMethods ?? Array.Empty<ExternalSystemMethod>();
DiffChildren(
existingForCompare,
incoming.Methods,
e => e.Name,
i => i.Name,
ExternalSystemMethodsEqual,
"Methods",
changes);
return BuildItem("ExternalSystem", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming database connection against the existing database connection in the database.
/// </summary>
/// <param name="incoming">The incoming database connection from the bundle.</param>
/// <param name="existing">The existing database connection in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareDatabaseConnection(DatabaseConnectionDto incoming, DatabaseConnectionDefinition? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("DatabaseConnection", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
// ConnectionString lives in Secrets only — presence-only comparison.
var existingHasSecret = !string.IsNullOrEmpty(existing.ConnectionString);
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("ConnectionString");
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange("Secrets.ConnectionString",
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
return BuildItem("DatabaseConnection", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming notification list against the existing notification list in the database.
/// </summary>
/// <param name="incoming">The incoming notification list from the bundle.</param>
/// <param name="existing">The existing notification list in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareNotificationList(NotificationListDto incoming, NotificationList? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("NotificationList", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Type", existing.Type.ToString(), incoming.Type.ToString());
DiffChildren(
existing.Recipients,
incoming.Recipients,
e => e.Name,
i => i.Name,
// A recipient is unchanged only when BOTH contacts match — a phone
// number added/changed on an existing recipient is a real diff.
(e, i) => e.EmailAddress == i.EmailAddress && e.PhoneNumber == i.PhoneNumber,
"Recipients",
changes);
return BuildItem("NotificationList", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming SMTP configuration against the existing SMTP configuration in the database.
/// </summary>
/// <param name="incoming">The incoming SMTP configuration from the bundle.</param>
/// <param name="existing">The existing SMTP configuration in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareSmtpConfiguration(SmtpConfigDto incoming, SmtpConfiguration? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("SmtpConfiguration", incoming.Host);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Port", existing.Port, incoming.Port);
AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);
AddIfDifferent(changes, "FromAddress", existing.FromAddress, incoming.FromAddress);
AddIfDifferent(changes, "TlsMode", existing.TlsMode, incoming.TlsMode);
AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds);
AddIfDifferent(changes, "MaxConcurrentConnections", existing.MaxConcurrentConnections, incoming.MaxConcurrentConnections);
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
var existingHasSecret = !string.IsNullOrEmpty(existing.Credentials);
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("Credentials");
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange("Secrets.Credentials",
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
return BuildItem("SmtpConfiguration", incoming.Host, changes);
}
/// <summary>
/// Compares an incoming SMS provider configuration against the existing one in
/// the database. Mirrors <see cref="CompareSmtpConfiguration"/>: keyed by
/// the natural key <c>AccountSid</c>, with the auth token diffed presence-only
/// (it lives in <see cref="SecretsBlock"/>, never compared by value).
/// </summary>
/// <param name="incoming">The incoming SMS configuration from the bundle.</param>
/// <param name="existing">The existing SMS configuration in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareSmsConfiguration(SmsConfigDto incoming, SmsConfiguration? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("SmsConfiguration", incoming.AccountSid);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "FromNumber", existing.FromNumber, incoming.FromNumber);
AddIfDifferent(changes, "MessagingServiceSid", existing.MessagingServiceSid, incoming.MessagingServiceSid);
AddIfDifferent(changes, "ApiBaseUrl", existing.ApiBaseUrl, incoming.ApiBaseUrl);
AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds);
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
var existingHasSecret = !string.IsNullOrEmpty(existing.AuthToken);
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthToken");
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange("Secrets.AuthToken",
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
return BuildItem("SmsConfiguration", incoming.AccountSid, changes);
}
// CompareApiKey was removed: inbound API keys are not transported
// between environments, so the import preview never diffs keys.
/// <summary>
/// Compares an incoming API method against the existing API method in the database.
/// </summary>
/// <param name="incoming">The incoming API method from the bundle.</param>
/// <param name="existing">The existing API method in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareApiMethod(ApiMethodDto incoming, ApiMethod? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("ApiMethod", incoming.Name);
var changes = new List<FieldChange>();
// ApprovedApiKeyIds is not transported and is excluded from the diff.
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds);
AddCodeChangeIfDifferent(changes, "Script", existing.Script, incoming.Script);
return BuildItem("ApiMethod", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming template folder against the existing template folder in the database.
/// </summary>
/// <param name="incoming">The incoming template folder from the bundle.</param>
/// <param name="existing">The existing template folder in the database, or null if new.</param>
/// <param name="folderNameById">A mapping of folder IDs to names for resolving parent folder references.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareTemplateFolder(TemplateFolderDto incoming, TemplateFolder? existing, IReadOnlyDictionary<int, string> folderNameById)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("TemplateFolder", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "SortOrder", existing.SortOrder, incoming.SortOrder);
var existingParentName = existing.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var n) ? n : null;
AddIfDifferent(changes, "ParentName", existingParentName, incoming.ParentName);
return BuildItem("TemplateFolder", incoming.Name, changes);
}
// ---- Site / Connection / Instance ----
/// <summary>
/// Compares an incoming site against the existing site in the database.
/// Identity is the <c>SiteIdentifier</c>; the diff is coarse over the
/// display name, description, and the four cluster/gRPC node addresses.
/// </summary>
/// <param name="incoming">The incoming site from the bundle.</param>
/// <param name="existing">The existing site in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareSite(SiteDto incoming, Site? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("Site", incoming.SiteIdentifier);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Name", existing.Name, incoming.Name);
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
AddIfDifferent(changes, "NodeAAddress", existing.NodeAAddress, incoming.NodeAAddress);
AddIfDifferent(changes, "NodeBAddress", existing.NodeBAddress, incoming.NodeBAddress);
AddIfDifferent(changes, "GrpcNodeAAddress", existing.GrpcNodeAAddress, incoming.GrpcNodeAAddress);
AddIfDifferent(changes, "GrpcNodeBAddress", existing.GrpcNodeBAddress, incoming.GrpcNodeBAddress);
return BuildItem("Site", incoming.SiteIdentifier, changes);
}
/// <summary>
/// Compares an incoming site-scoped data connection (the <c>Sites.DataConnection</c>
/// entity, NOT the External-System database connection) against the existing one.
/// The Primary/Backup protocol configuration lives in the DTO's
/// <see cref="SecretsBlock"/>, so it is compared <b>presence-only</b> — the diff
/// never echoes the configuration value, mirroring the external-system / DB-connection
/// secret handling.
/// </summary>
/// <param name="incoming">The incoming data connection from the bundle.</param>
/// <param name="existing">The existing data connection in the database, or null if new.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareDataConnection(DataConnectionDto incoming, DataConnection? existing)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("DataConnection", incoming.Name);
var changes = new List<FieldChange>();
AddIfDifferent(changes, "Protocol", existing.Protocol, incoming.Protocol);
AddIfDifferent(changes, "FailoverRetryCount", existing.FailoverRetryCount, incoming.FailoverRetryCount);
// Protocol config rides in Secrets (PrimaryConfiguration / BackupConfiguration).
// Presence-only comparison — never echo the config value.
AddSecretPresenceChange(changes, "Secrets.PrimaryConfiguration",
!string.IsNullOrEmpty(existing.PrimaryConfiguration),
HasSecretKey(incoming.Secrets, "PrimaryConfiguration"));
AddSecretPresenceChange(changes, "Secrets.BackupConfiguration",
!string.IsNullOrEmpty(existing.BackupConfiguration),
HasSecretKey(incoming.Secrets, "BackupConfiguration"));
return BuildItem("DataConnection", incoming.Name, changes);
}
/// <summary>
/// Compares an incoming deployable instance against the existing one. Identity
/// is the <c>UniqueName</c>; the diff is coarse over the template/site/area/state
/// scalar fields plus a name-keyed child diff over the four override/binding
/// collections.
/// <para>
/// The caller is responsible for passing a <b>hydrated</b> <see cref="Instance"/>
/// (its <c>AttributeOverrides</c>, <c>AlarmOverrides</c>, <c>NativeAlarmSourceOverrides</c>,
/// and <c>ConnectionBindings</c> navigation collections eagerly loaded). A
/// non-hydrated entity reads as having no children, which would surface every
/// incoming child as an addition.
/// </para>
/// </summary>
/// <param name="incoming">The incoming instance from the bundle.</param>
/// <param name="existing">The existing (hydrated) instance in the database, or null if new.</param>
/// <param name="existingTemplateName">The existing instance's template name (the entity carries only a FK), or null if unknown.</param>
/// <param name="existingSiteIdentifier">The existing instance's site identifier (the entity carries only a FK), or null if unknown.</param>
/// <param name="existingAreaName">The existing instance's area name (the entity carries only a FK), or null if unknown / unset.</param>
/// <returns>An import preview item describing the conflict type and differences.</returns>
public ImportPreviewItem CompareInstance(
InstanceDto incoming,
Instance? existing,
string? existingTemplateName = null,
string? existingSiteIdentifier = null,
string? existingAreaName = null)
{
ArgumentNullException.ThrowIfNull(incoming);
if (existing is null) return New("Instance", incoming.UniqueName);
var changes = new List<FieldChange>();
// The entity stores template/site/area as numeric FKs that can't be compared
// cross-environment; the caller resolves them to names. When a name wasn't
// supplied we skip that scalar rather than emit a spurious "<id:N>" diff.
if (existingTemplateName is not null)
{
AddIfDifferent(changes, "TemplateName", existingTemplateName, incoming.TemplateName);
}
if (existingSiteIdentifier is not null)
{
AddIfDifferent(changes, "SiteIdentifier", existingSiteIdentifier, incoming.SiteIdentifier);
}
AddIfDifferent(changes, "AreaName", existingAreaName, incoming.AreaName);
AddIfDifferent(changes, "State", existing.State.ToString(), incoming.State.ToString());
DiffChildren(
existing.AttributeOverrides,
incoming.AttributeOverrides,
e => e.AttributeName,
i => i.AttributeName,
(e, i) => e.OverrideValue == i.OverrideValue && e.ElementDataType == i.ElementDataType,
"AttributeOverrides",
changes);
DiffChildren(
existing.AlarmOverrides,
incoming.AlarmOverrides,
e => e.AlarmCanonicalName,
i => i.AlarmCanonicalName,
(e, i) => e.TriggerConfigurationOverride == i.TriggerConfigurationOverride
&& e.PriorityLevelOverride == i.PriorityLevelOverride,
"AlarmOverrides",
changes);
DiffChildren(
existing.NativeAlarmSourceOverrides,
incoming.NativeAlarmSourceOverrides,
e => e.SourceCanonicalName,
i => i.SourceCanonicalName,
(e, i) => e.ConnectionNameOverride == i.ConnectionNameOverride
&& e.SourceReferenceOverride == i.SourceReferenceOverride
&& e.ConditionFilterOverride == i.ConditionFilterOverride,
"NativeAlarmSourceOverrides",
changes);
DiffChildren(
existing.ConnectionBindings,
incoming.ConnectionBindings,
e => e.AttributeName,
i => i.AttributeName,
// ConnectionName resolves to a FK on the entity (DataConnectionId) that
// can't be compared cross-environment, so the binding diff compares only
// the per-attribute DataSourceReference override.
(e, i) => e.DataSourceReferenceOverride == i.DataSourceReferenceOverride,
"ConnectionBindings",
changes);
return BuildItem("Instance", incoming.UniqueName, changes);
}
// ---- Helpers ----
private static ImportPreviewItem New(string entityType, string name) =>
new(entityType, name, ExistingVersion: null, IncomingVersion: null, ConflictKind.New, FieldDiffJson: null, BlockerReason: null);
private static ImportPreviewItem BuildItem(string entityType, string name, List<FieldChange> changes)
{
if (changes.Count == 0)
{
return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null);
}
var diff = new FieldDiff(
Adds: Array.Empty<string>(),
Removes: Array.Empty<string>(),
Changes: changes);
return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Modified, FieldDiffJson: JsonSerializer.Serialize(diff, DiffJsonOptions), BlockerReason: null);
}
private static void AddIfDifferent<T>(List<FieldChange> changes, string field, T existing, T incoming)
{
if (!Equals(existing, incoming))
{
changes.Add(new FieldChange(field, existing?.ToString(), incoming?.ToString()));
}
}
private static void AddCodeChangeIfDifferent(List<FieldChange> changes, string field, string? existing, string? incoming)
{
// Code/large-text fields: when they differ, emit a real per-line Myers diff.
// The OldValue/NewValue keep a compact "<N lines>" summary as a
// fallback for renderers that don't consume the structured payload; the
// LineDiff carries the +/- hunk lines. Identical code emits no change.
var sameNullness = existing is null == incoming is null;
var bothPresentAndEqual = sameNullness && (existing is null || string.Equals(existing, incoming, StringComparison.Ordinal));
if (bothPresentAndEqual) return;
changes.Add(BuildCodeFieldChange(field, existing, incoming));
}
/// <summary>
/// Builds a <see cref="FieldChange"/> for a code/large-text field carrying both
/// the compact line-count summary and the structured per-line Myers diff. Only
/// ever called for the named code fields (TemplateScript.Code, SharedScript.Code,
/// ApiMethod.Script) — never for secret-bearing fields — so the verbatim line
/// text emitted here is never sensitive.
/// </summary>
private static FieldChange BuildCodeFieldChange(string field, string? existing, string? incoming)
{
var oldLines = existing?.Split('\n').Length ?? 0;
var newLines = incoming?.Split('\n').Length ?? 0;
// LineDiffer caps at maxLines (default 400); the payload's Truncated flag
// tells the UI when the hunk list was clipped so the JSON stays bounded.
var diff = LineDiffer.Diff(existing, incoming);
return new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>", ToPayload(diff));
}
private static LineDiffPayload ToPayload(LineDiffResult diff)
{
var hunks = new List<LineDiffHunk>(diff.Lines.Count);
foreach (var line in diff.Lines)
{
hunks.Add(new LineDiffHunk(OpName(line.Op), line.Text, line.OldLineNo, line.NewLineNo));
}
return new LineDiffPayload(hunks, diff.Truncated, diff.AddedCount, diff.RemovedCount);
}
private static string OpName(LineDiffOp op) => op switch
{
LineDiffOp.Add => "add",
LineDiffOp.Remove => "remove",
_ => "context",
};
/// <summary>True if <paramref name="secrets"/> carries the named key.</summary>
private static bool HasSecretKey(SecretsBlock? secrets, string key) =>
secrets is not null && secrets.Values.ContainsKey(key);
/// <summary>
/// Records a presence-only secret change (a flip in whether a value is set on
/// each side) using the <c>&lt;present&gt;</c> / null marker convention. The
/// secret value itself is NEVER read into the diff — only its presence.
/// </summary>
private static void AddSecretPresenceChange(List<FieldChange> changes, string field, bool existingHasSecret, bool incomingHasSecret)
{
if (existingHasSecret != incomingHasSecret)
{
changes.Add(new FieldChange(field,
existingHasSecret ? "<present>" : null,
incomingHasSecret ? "<present>" : null));
}
}
private static void DiffChildren<TExisting, TIncoming>(
IEnumerable<TExisting> existing,
IEnumerable<TIncoming> incoming,
Func<TExisting, string> existingKey,
Func<TIncoming, string> incomingKey,
Func<TExisting, TIncoming, bool> equal,
string childCategory,
List<FieldChange> changes)
{
var existingByName = existing.GroupBy(existingKey, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var incomingByName = incoming.GroupBy(incomingKey, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var added = incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
var removed = existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
foreach (var name in added)
{
changes.Add(new FieldChange($"{childCategory}.{name}", null, "<added>"));
}
foreach (var name in removed)
{
changes.Add(new FieldChange($"{childCategory}.{name}", "<present>", null));
}
foreach (var (name, e) in existingByName)
{
if (!incomingByName.TryGetValue(name, out var i)) continue;
if (!equal(e, i))
{
changes.Add(new FieldChange($"{childCategory}.{name}", "<modified>", "<modified>"));
}
}
}
/// <summary>
/// Scripts get a finer-grained per-row line-count delta so the preview UI
/// can show the operator which scripts changed and roughly how much.
/// </summary>
private static void DiffScriptChildren(
IEnumerable<TemplateScript> existing,
IEnumerable<TemplateScriptDto> incoming,
List<FieldChange> changes)
{
var existingByName = existing.GroupBy(s => s.Name, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var incomingByName = incoming.GroupBy(s => s.Name, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
foreach (var name in incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
{
var inc = incomingByName[name];
var newLines = inc.Code.Split('\n').Length;
changes.Add(new FieldChange($"Scripts.{name}", null, $"<added, {newLines} lines>"));
}
foreach (var name in existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
{
var ex = existingByName[name];
var oldLines = ex.Code.Split('\n').Length;
changes.Add(new FieldChange($"Scripts.{name}", $"<present, {oldLines} lines>", null));
}
foreach (var (name, ex) in existingByName)
{
if (!incomingByName.TryGetValue(name, out var inc)) continue;
if (!TemplateChildEquality.ScriptsEqual(ex, inc))
{
// A script row can diverge in Code and/or its trigger/param metadata.
// The structured line diff covers the Code body; when only the
// metadata changed the hunk list is all-context (no +/-), which still
// tells the operator the row changed.
changes.Add(BuildCodeFieldChange($"Scripts.{name}", ex.Code, inc.Code));
}
}
}
private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) =>
e.HttpMethod == i.HttpMethod
&& e.Path == i.Path
&& e.ParameterDefinitions == i.ParameterDefinitions
&& e.ReturnDefinition == i.ReturnDefinition;
private static string? FolderNameOf(Template t)
{
// Templates carry only a FK to the folder; the EntitySerializer projects
// it to a name. The diff doesn't have access to a folder lookup at
// CompareTemplate scope, so fall back to "<id:N>" when only the id is
// known. The PreviewAsync caller can pass a hydrated Template (via
// GetTemplateWithChildrenAsync) and the folder name typically isn't on
// it — this branch is a deliberate best-effort.
return t.FolderId is null ? null : $"<id:{t.FolderId}>";
}
private static string? BaseTemplateNameOf(Template t)
{
return t.ParentTemplateId is null ? null : $"<id:{t.ParentTemplateId}>";
}
private static string CompositionTargetNameOf(TemplateComposition comp)
{
return $"<id:{comp.ComposedTemplateId}>";
}
private sealed record FieldChange(
[property: JsonPropertyName("field")] string Field,
[property: JsonPropertyName("oldValue")] string? OldValue,
[property: JsonPropertyName("newValue")] string? NewValue,
// Present only for code / large-text fields. Null (and omitted from
// the JSON by the WhenWritingNull policy) for ordinary coarse fields.
[property: JsonPropertyName("lineDiff")] LineDiffPayload? LineDiff = null);
/// <summary>
/// Serializable projection of <see cref="LineDiffResult"/> embedded inside a
/// code <see cref="FieldChange"/>. <see cref="Hunks"/> is in source order and
/// may be truncated (see <see cref="Truncated"/>); <see cref="AddedCount"/> /
/// <see cref="RemovedCount"/> always report the full diff totals. The UI (E2)
/// renders the +/- view directly from <see cref="Hunks"/>.
/// </summary>
private sealed record LineDiffPayload(
[property: JsonPropertyName("hunks")] IReadOnlyList<LineDiffHunk> Hunks,
[property: JsonPropertyName("truncated")] bool Truncated,
[property: JsonPropertyName("addedCount")] int AddedCount,
[property: JsonPropertyName("removedCount")] int RemovedCount);
/// <summary>
/// One emitted line of a code line-diff. <see cref="Op"/> is the lower-case
/// op name (<c>context</c> / <c>add</c> / <c>remove</c>); line numbers are
/// 1-based and null where the side does not participate (an <c>add</c> has no
/// old line number, a <c>remove</c> has no new line number).
/// </summary>
private sealed record LineDiffHunk(
[property: JsonPropertyName("op")] string Op,
[property: JsonPropertyName("text")] string Text,
[property: JsonPropertyName("oldLineNo")] int? OldLineNo,
[property: JsonPropertyName("newLineNo")] int? NewLineNo);
private sealed record FieldDiff(
[property: JsonPropertyName("adds")] IReadOnlyList<string> Adds,
[property: JsonPropertyName("removes")] IReadOnlyList<string> Removes,
[property: JsonPropertyName("changes")] IReadOnlyList<FieldChange> Changes);
}