using System.Text.Json; using ScadaLink.Commons.Types.Flattening; namespace ScadaLink.TemplateEngine.Validation; /// /// Pre-deployment validation pipeline. Validates a FlattenedConfiguration for correctness /// before deployment. Also available on-demand (same logic, no deployment trigger). /// /// Validation checks: /// 1. Flattening success (no empty configuration) /// 2. No naming collisions /// 3. Script compilation (via ScriptCompiler) /// 4. Alarm trigger references exist (referenced attributes must be in the flattened config) /// 5. Script trigger references exist (referenced attributes must be in the flattened config) /// 6. Connection binding completeness (all data-sourced attributes must have a binding) /// 7. Does NOT verify tag path resolution on devices /// public class ValidationService { private readonly SemanticValidator _semanticValidator; private readonly ScriptCompiler _scriptCompiler; public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler) { _semanticValidator = semanticValidator; _scriptCompiler = scriptCompiler; } /// /// Convenience constructor that creates default dependencies. /// public ValidationService() : this(new SemanticValidator(), new ScriptCompiler()) { } /// /// Runs the full validation pipeline on a flattened configuration. /// public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList? sharedScripts = null) { ArgumentNullException.ThrowIfNull(configuration); var results = new List { ValidateFlatteningSuccess(configuration), ValidateNamingCollisions(configuration), ValidateScriptCompilation(configuration), ValidateAlarmTriggerReferences(configuration), ValidateScriptTriggerReferences(configuration), ValidateConnectionBindingCompleteness(configuration), _semanticValidator.Validate(configuration, sharedScripts) }; return ValidationResult.Merge(results.ToArray()); } /// /// Validates that flattening produced a non-empty configuration. /// public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration) { var errors = new List(); if (string.IsNullOrWhiteSpace(configuration.InstanceUniqueName)) errors.Add(ValidationEntry.Error(ValidationCategory.FlatteningFailure, "Instance unique name is missing.")); if (configuration.Attributes.Count == 0 && configuration.Alarms.Count == 0 && configuration.Scripts.Count == 0) { return new ValidationResult { Warnings = [ValidationEntry.Warning(ValidationCategory.FlatteningFailure, "Flattened configuration contains no attributes, alarms, or scripts.")] }; } return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that there are no naming collisions across entity types. /// Canonical names must be unique within their entity type (attributes, alarms, scripts). /// public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration) { var errors = new List(); CheckDuplicates(configuration.Attributes, a => a.CanonicalName, "Attribute", errors); CheckDuplicates(configuration.Alarms, a => a.CanonicalName, "Alarm", errors); CheckDuplicates(configuration.Scripts, s => s.CanonicalName, "Script", errors); return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that all scripts compile successfully using the ScriptCompiler. /// public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration) { var errors = new List(); var warnings = new List(); foreach (var script in configuration.Scripts) { var result = _scriptCompiler.TryCompile(script.Code, script.CanonicalName); if (!result.IsSuccess) { errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation, $"Script '{script.CanonicalName}' failed compilation: {result.Error}", script.CanonicalName)); } } return new ValidationResult { Errors = errors, Warnings = warnings }; } /// /// Validates that alarm trigger configurations reference existing attributes. /// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name. /// public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration) { var errors = new List(); var attributeNames = new HashSet( configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal); foreach (var alarm in configuration.Alarms) { if (string.IsNullOrWhiteSpace(alarm.TriggerConfiguration)) continue; var attrName = ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration); if (attrName != null && !attributeNames.Contains(attrName)) { errors.Add(ValidationEntry.Error(ValidationCategory.AlarmTriggerReference, $"Alarm '{alarm.CanonicalName}' references attribute '{attrName}' which does not exist in the flattened configuration.", alarm.CanonicalName)); } } return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that script trigger configurations reference existing attributes. /// public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration) { var errors = new List(); var attributeNames = new HashSet( configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal); foreach (var script in configuration.Scripts) { if (string.IsNullOrWhiteSpace(script.TriggerConfiguration)) continue; var attrName = ExtractAttributeNameFromTriggerConfig(script.TriggerConfiguration); if (attrName != null && !attributeNames.Contains(attrName)) { errors.Add(ValidationEntry.Error(ValidationCategory.ScriptTriggerReference, $"Script '{script.CanonicalName}' trigger references attribute '{attrName}' which does not exist in the flattened configuration.", script.CanonicalName)); } } return errors.Count > 0 ? new ValidationResult { Errors = errors } : ValidationResult.Success(); } /// /// Validates that all data-sourced attributes have connection bindings. /// public static ValidationResult ValidateConnectionBindingCompleteness(FlattenedConfiguration configuration) { var errors = new List(); var warnings = new List(); foreach (var attr in configuration.Attributes) { if (attr.DataSourceReference != null && attr.BoundDataConnectionId == null) { warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding, $"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.", attr.CanonicalName)); } } return new ValidationResult { Errors = errors, Warnings = warnings }; } private static void CheckDuplicates( IReadOnlyList items, Func getName, string entityType, List errors) { var seen = new HashSet(StringComparer.Ordinal); foreach (var item in items) { var name = getName(item); if (!seen.Add(name)) { errors.Add(ValidationEntry.Error(ValidationCategory.NamingCollision, $"{entityType} naming collision: '{name}' appears more than once.", name)); } } } internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson) { // Accept both keys to stay consistent with FlatteningService.PrefixTriggerAttribute, // AlarmActor.ParseEvalConfig and AlarmTriggerConfigCodec. Old data may still use // "attribute"; the UI codec writes the canonical "attributeName". try { using var doc = JsonDocument.Parse(triggerConfigJson); if (doc.RootElement.TryGetProperty("attributeName", out var prop)) return prop.GetString(); if (doc.RootElement.TryGetProperty("attribute", out var legacyProp)) return legacyProp.GetString(); } catch (JsonException) { // Not valid JSON, ignore } return null; } /// /// Extracts the four HiLo setpoints from a trigger configuration JSON. /// Any unset (or non-numeric) setpoint comes back as null. Returns /// all-nulls on malformed JSON — callers should treat that as "nothing to /// validate" and let other checks surface the deeper problem. /// internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson) { try { using var doc = JsonDocument.Parse(triggerConfigJson); var root = doc.RootElement; return new HiLoSetpoints( LoLo: ReadDouble(root, "loLo"), Lo: ReadDouble(root, "lo"), Hi: ReadDouble(root, "hi"), HiHi: ReadDouble(root, "hiHi"), LoLoDeadband: ReadDouble(root, "loLoDeadband"), LoDeadband: ReadDouble(root, "loDeadband"), HiDeadband: ReadDouble(root, "hiDeadband"), HiHiDeadband: ReadDouble(root, "hiHiDeadband")); } catch (JsonException) { return new HiLoSetpoints(null, null, null, null, null, null, null, null); } } private static double? ReadDouble(JsonElement el, string name) { if (!el.TryGetProperty(name, out var p)) return null; return p.ValueKind switch { JsonValueKind.Number => p.GetDouble(), JsonValueKind.String when double.TryParse(p.GetString(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v) => v, _ => null }; } } internal readonly record struct HiLoSetpoints( double? LoLo, double? Lo, double? Hi, double? HiHi, double? LoLoDeadband = null, double? LoDeadband = null, double? HiDeadband = null, double? HiHiDeadband = null);