diff --git a/src/TALXIS.Platform.Metadata.Validation/SolutionValidator.cs b/src/TALXIS.Platform.Metadata.Validation/SolutionValidator.cs new file mode 100644 index 0000000..e59bb3f --- /dev/null +++ b/src/TALXIS.Platform.Metadata.Validation/SolutionValidator.cs @@ -0,0 +1,193 @@ +using TALXIS.Platform.Metadata.Serialization.Xml; +using TALXIS.Platform.Metadata.Components; + +namespace TALXIS.Platform.Metadata.Validation; + +/// +/// Validates a single solution root with every rule that needs no cross-solution knowledge: +/// XSD and JSON schema checks, duplicate GUID detection, model load, flow and solution manifest +/// rules. Relationship rules are workspace-scoped (a relationship and the entity it references +/// may ship in different solutions), so they live in . +/// composes this validator over each solution it discovers; +/// build pipelines call it directly on one solution source folder. +/// +public sealed class SolutionValidator +{ + private readonly SchemaValidator _schemaValidator; + private readonly JsonValidator _jsonValidator; + + /// + /// Creates a validator with freshly compiled embedded schema sets. + /// + public SolutionValidator() : this(new SchemaValidator(), new JsonValidator()) { } + + internal SolutionValidator(SchemaValidator schemaValidator, JsonValidator jsonValidator) + { + _schemaValidator = schemaValidator; + _jsonValidator = jsonValidator; + } + + /// + /// Runs all single-solution checks on a solution root directory. + /// + /// Directory containing the unpacked solution (the folder with Other/Solution.xml). + public WorkspaceValidationReport Validate(string solutionRootPath) + { + var results = new List(); + + if (!Directory.Exists(solutionRootPath)) + { + results.Add(new ValidationResult(ValidationSeverity.Error, + $"Directory not found: {solutionRootPath}", null, null, null)); + return WorkspaceValidator.BuildReport(results, null); + } + + if (!File.Exists(Path.Combine(solutionRootPath, "Other", "Solution.xml"))) + { + results.Add(new ValidationResult(ValidationSeverity.Warning, + $"No Other/Solution.xml found under '{solutionRootPath}'. If this directory holds multiple solutions, validate it with {nameof(WorkspaceValidator)} instead.", + null, null, null) { Code = ValidationDiagnostics.SolutionManifestFileAbsent }); + } + + CollectFileFindings(solutionRootPath, results); + + var workspace = TryLoad(solutionRootPath, results); + if (workspace != null) + CollectModelFindingsSafe(workspace, results, solutionRootPath); + + return WorkspaceValidator.BuildReport(results, workspace); + } + + /// + /// Runs all single-solution checks against an already-loaded solution workspace. + /// + public WorkspaceValidationReport Validate(Workspace solution, string solutionRootPath) + { + if (solution == null) throw new ArgumentNullException(nameof(solution)); + + var results = new List(); + if (Directory.Exists(solutionRootPath)) + CollectFileFindings(solutionRootPath, results); + + CollectModelFindingsSafe(solution, results, solutionRootPath); + return WorkspaceValidator.BuildReport(results, solution); + } + + /// + /// Model checks with the same safety net the load has: a crashing rule becomes a finding, + /// not an exception escaping to the consumer. + /// + internal void CollectModelFindingsSafe(Workspace workspace, List results, string solutionRootPath) + { + try + { + CollectModelFindings(workspace, results); + } + catch (Exception ex) + { + results.Add(new ValidationResult( + ValidationSeverity.Error, + $"Failed to load workspace into model: {ex.Message}", + solutionRootPath, null, null) { Stage = ValidationStage.ModelLoad }); + } + } + + /// + /// File-level checks for one solution root: XSD schemas, JSON schemas, duplicate GUIDs. + /// + internal void CollectFileFindings(string solutionRootPath, List results) + { + CollectSchemaAndJsonFindings(solutionRootPath, results); + results.AddRange(WithStage(new GuidValidator().ValidateDirectory(solutionRootPath), ValidationStage.DuplicateGuid)); + } + + /// + /// XSD and JSON checks only, optionally restricted to files matching . + /// + internal void CollectSchemaAndJsonFindings(string directory, List results, Func? includeFile = null) + { + ValidateFiles(directory, "*.xml", ValidationStage.Schema, + file => WorkspaceFiles.IsWebResourcePayload(file) ? Array.Empty() : _schemaValidator.ValidateFile(file), + results, includeFile); + + ValidateFiles(directory, "*.json", ValidationStage.Json, _jsonValidator.ValidateFile, results, includeFile); + } + + /// + /// Model-level checks for one loaded solution: load errors, flow diagnostics, + /// solution manifest rules. + /// + private void CollectModelFindings(Workspace workspace, List results) + { + foreach (var loadError in workspace.LoadErrors) + { + results.Add(new ValidationResult( + ValidationSeverity.Error, + $"Load error: {loadError.Message}", + loadError.FilePath, + loadError.Line, + loadError.Column) { Stage = ValidationStage.ModelLoad }); + } + + foreach (var diagnostic in workspace.FlowDefinitions.SelectMany(f => f.Diagnostics)) + { + results.Add(new ValidationResult( + MapFlowSeverity(diagnostic.Severity), + $"Flow {diagnostic.Code}: {diagnostic.Message}", + diagnostic.FilePath, + diagnostic.Line, + diagnostic.Column) { Stage = ValidationStage.Flow }); + } + + results.AddRange(new SolutionManifestValidator().Validate(workspace)); + } + + internal static Workspace? TryLoad(string solutionRootPath, List results) + { + try + { + return new XmlWorkspaceReader().Load(solutionRootPath); + } + catch (Exception ex) + { + results.Add(new ValidationResult( + ValidationSeverity.Error, + $"Failed to load workspace into model: {ex.Message}", + solutionRootPath, null, null) { Stage = ValidationStage.ModelLoad }); + return null; + } + } + + private static void ValidateFiles( + string directory, + string pattern, + ValidationStage stage, + Func> validate, + List results, + Func? includeFile) + { + foreach (var file in WorkspaceFiles.Enumerate(directory, pattern)) + { + if (includeFile != null && !includeFile(file)) continue; + + try + { + results.AddRange(WithStage(validate(file), stage)); + } + catch (IOException ex) + { + results.Add(new ValidationResult(ValidationSeverity.Warning, + $"Cannot read file: {ex.Message}", file, null, null) { Stage = stage }); + } + catch (UnauthorizedAccessException ex) + { + results.Add(new ValidationResult(ValidationSeverity.Warning, + $"Access denied: {ex.Message}", file, null, null) { Stage = stage }); + } + } + } + + internal static IEnumerable WithStage(IEnumerable results, ValidationStage stage) => results.Select(r => r with { Stage = stage }); + + private static ValidationSeverity MapFlowSeverity(FlowDiagnosticSeverity severity) => severity == FlowDiagnosticSeverity.Error ? ValidationSeverity.Error : ValidationSeverity.Warning; +} diff --git a/src/TALXIS.Platform.Metadata.Validation/ValidationDiagnostics.cs b/src/TALXIS.Platform.Metadata.Validation/ValidationDiagnostics.cs index 86a1f2b..be4b554 100644 --- a/src/TALXIS.Platform.Metadata.Validation/ValidationDiagnostics.cs +++ b/src/TALXIS.Platform.Metadata.Validation/ValidationDiagnostics.cs @@ -21,4 +21,7 @@ public static class ValidationDiagnostics /// A solution manifest declares more than one root component of the same type with the same identity (schema name or id). public const string DuplicateRootComponent = "TXM004"; + + /// The directory passed to solution validation has no Other/Solution.xml manifest. + public const string SolutionManifestFileAbsent = "TXM005"; } diff --git a/src/TALXIS.Platform.Metadata.Validation/WorkspaceFiles.cs b/src/TALXIS.Platform.Metadata.Validation/WorkspaceFiles.cs new file mode 100644 index 0000000..da2c27f --- /dev/null +++ b/src/TALXIS.Platform.Metadata.Validation/WorkspaceFiles.cs @@ -0,0 +1,33 @@ +namespace TALXIS.Platform.Metadata.Validation; + +/// +/// Shared file-enumeration rules for validators that scan a workspace tree. +/// +internal static class WorkspaceFiles +{ + internal static readonly HashSet IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase) + { + "bin", "obj", ".vs", ".git", ".github", "node_modules", "packages", "TestResults" + }; + + internal static IEnumerable Enumerate(string directory, string pattern) + { + var fullDir = Path.GetFullPath(directory); + foreach (var file in Directory.EnumerateFiles(directory, pattern, SearchOption.AllDirectories)) + { + var fullFile = Path.GetFullPath(file); + var relativePath = fullFile.Substring(fullDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (parts.Any(p => IgnoredDirectories.Contains(p))) + continue; + yield return file; + } + } + + internal static bool IsWebResourcePayload(string filePath) + { + if (filePath.EndsWith(".data.xml", StringComparison.OrdinalIgnoreCase)) return false; + var normalized = filePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + return normalized.IndexOf($"{Path.DirectorySeparatorChar}WebResources{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) >= 0; + } +} diff --git a/src/TALXIS.Platform.Metadata.Validation/WorkspaceValidator.cs b/src/TALXIS.Platform.Metadata.Validation/WorkspaceValidator.cs index bca8fec..56c5e8c 100644 --- a/src/TALXIS.Platform.Metadata.Validation/WorkspaceValidator.cs +++ b/src/TALXIS.Platform.Metadata.Validation/WorkspaceValidator.cs @@ -1,40 +1,18 @@ using TALXIS.Platform.Metadata.Serialization.Xml; -using TALXIS.Platform.Metadata.Components; namespace TALXIS.Platform.Metadata.Validation; /// -/// Unified validation entry point. Runs all registered validators -/// and optionally loads the workspace into the typed model. -/// Consumers call this single method instead of wiring validators individually. +/// Unified validation entry point for a whole workspace. Discovers every solution root, +/// runs on each, then adds the checks that only make sense +/// across solutions: cross-solution duplicate GUIDs, files outside any solution root, and +/// the combined workspace model. /// public sealed class WorkspaceValidator { - private static readonly HashSet IgnoredDirectories = new(StringComparer.OrdinalIgnoreCase) - { - "bin", "obj", ".vs", ".git", ".github", "node_modules", "packages", "TestResults" - }; - - - private static IEnumerable EnumerateWorkspaceFiles(string directory, string pattern) - { - var fullDir = Path.GetFullPath(directory); - foreach (var file in Directory.EnumerateFiles(directory, pattern, SearchOption.AllDirectories)) - { - // Check if any parent directory is in the ignore list - var fullFile = Path.GetFullPath(file); - var relativePath = fullFile.Substring(fullDir.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var parts = relativePath.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }); - if (parts.Any(p => IgnoredDirectories.Contains(p))) - continue; - yield return file; - } - } - /// /// Runs all validation checks on a solution workspace directory: - /// XSD schema validation, JSON schema validation, duplicate GUID detection, - /// and model loading (reports parse errors and component counts). + /// per-solution checks (XSD, JSON, GUIDs, model rules) plus cross-solution checks. /// /// Path to the unpacked SolutionPackager workspace. /// A validation report containing all findings and the loaded workspace when loading succeeded. @@ -50,76 +28,122 @@ public WorkspaceValidationReport ValidateDirectory(string workspacePath) return BuildReport(results, null); } - // Layer 1: XSD schema validation, one file at a time. - // WebResources payloads (arbitrary XML uploaded as web resources) are skipped - - // only their .data.xml descriptors have a schema. - var schemaValidator = new SchemaValidator(); - ValidateFiles(workspacePath, "*.xml", ValidationStage.Schema, - file => IsWebResourcePayload(file) ? Array.Empty() : schemaValidator.ValidateFile(file), - results); + var solutionRoots = DiscoverSolutionRoots(workspacePath); + var workspaceIsSingleRoot = solutionRoots.Count == 0; + if (workspaceIsSingleRoot) + solutionRoots = new[] { workspacePath }; + + var solutionValidator = new SolutionValidator(); + + // File-level checks per solution root, then files outside any root, so every file is + // visited exactly once. Duplicate GUID detection stays workspace-wide: its component + // identity rules already tell cross-solution layering apart from real duplicates. + foreach (var root in solutionRoots) + solutionValidator.CollectSchemaAndJsonFindings(root, results); + + if (!workspaceIsSingleRoot) + { + // Enumerated files and discovered roots share the workspacePath base, so ordinal + // prefix comparison is exact on every platform. + var rootPrefixes = solutionRoots + .Select(root => Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar) + .ToArray(); + + solutionValidator.CollectSchemaAndJsonFindings(workspacePath, results, + file => + { + var fullFile = Path.GetFullPath(file); + return !rootPrefixes.Any(prefix => fullFile.StartsWith(prefix, StringComparison.Ordinal)); + }); + } + + results.AddRange(SolutionValidator.WithStage( + new GuidValidator().ValidateDirectory(workspacePath), ValidationStage.DuplicateGuid)); - // Layer 2: JSON schema validation, one file at a time. - ValidateFiles(workspacePath, "*.json", ValidationStage.Json, new JsonValidator().ValidateFile, results); + var loaded = new List<(string Root, Workspace? Workspace)>(); + foreach (var root in solutionRoots) + loaded.Add((root, SolutionValidator.TryLoad(root, results))); - // Layer 3: duplicate GUID detection across the whole tree. - results.AddRange(WithStage(new GuidValidator().ValidateDirectory(workspacePath), ValidationStage.DuplicateGuid)); + foreach (var (root, solution) in loaded) + { + if (solution != null) + solutionValidator.CollectModelFindingsSafe(solution, results, root); + } - // Layer 4: load each solution into the model and validate it (load errors, flows, relationships). - var workspace = ValidateModel(workspacePath, results); + CollectRelationshipFindings(loaded, results); + var workspace = BuildCombinedWorkspace(workspacePath, solutionRoots, loaded, results); return BuildReport(results, workspace); } - private static bool IsWebResourcePayload(string filePath) + /// + /// Runs only the workspace-scoped relationship rules: every solution's relationships are + /// checked against the entities and columns of the whole workspace, since a relationship and + /// the entity it references may ship in different solutions. Meant for a single pre-build + /// pass over a multi-solution workspace; per-solution rules stay in . + /// + /// Path to the workspace root containing one or more unpacked solutions. + public WorkspaceValidationReport ValidateRelationships(string workspacePath) { - if (filePath.EndsWith(".data.xml", StringComparison.OrdinalIgnoreCase)) return false; - var normalized = filePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); - return normalized.IndexOf($"{Path.DirectorySeparatorChar}WebResources{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) >= 0; + var results = new List(); + + if (!Directory.Exists(workspacePath)) + { + results.Add(new ValidationResult(ValidationSeverity.Error, + $"Directory not found: {workspacePath}", null, null, null) { Stage = ValidationStage.Workspace }); + + return BuildReport(results, null); + } + + var solutionRoots = DiscoverSolutionRoots(workspacePath); + if (solutionRoots.Count == 0) + solutionRoots = new[] { workspacePath }; + + var loaded = new List<(string Root, Workspace? Workspace)>(); + foreach (var root in solutionRoots) + loaded.Add((root, SolutionValidator.TryLoad(root, results))); + + CollectRelationshipFindings(loaded, results); + + return BuildReport(results, null); } - private static void ValidateFiles( - string workspacePath, - string pattern, - ValidationStage stage, - Func> validate, - List results) + private static void CollectRelationshipFindings(List<(string Root, Workspace? Workspace)> loaded, List results) { - foreach (var file in EnumerateWorkspaceFiles(workspacePath, pattern)) + var workspaceColumns = BuildWorkspaceColumnSet(loaded.Select(l => l.Workspace).OfType()); + + foreach (var (root, solution) in loaded) { + if (solution == null) continue; + try { - results.AddRange(WithStage(validate(file), stage)); + results.AddRange(SolutionValidator.WithStage( + new RelationshipValidator().Validate(solution, workspaceColumns), ValidationStage.Relationship)); } - catch (IOException ex) + catch (Exception ex) { - results.Add(new ValidationResult(ValidationSeverity.Warning, - $"Cannot read file: {ex.Message}", file, null, null) { Stage = stage }); - } - catch (UnauthorizedAccessException ex) - { - results.Add(new ValidationResult(ValidationSeverity.Warning, - $"Access denied: {ex.Message}", file, null, null) { Stage = stage }); + results.Add(new ValidationResult( + ValidationSeverity.Error, + $"Failed to load workspace into model: {ex.Message}", + root, null, null) { Stage = ValidationStage.ModelLoad }); } } } - private static Workspace? ValidateModel(string workspacePath, List results) + private static Workspace? BuildCombinedWorkspace( + string workspacePath, + IReadOnlyList solutionRoots, + List<(string Root, Workspace? Workspace)> loaded, + List results) { + if (loaded.Any(l => l.Workspace == null)) return null; + if (loaded.Count == 1) return loaded[0].Workspace; + try { - var reader = new XmlWorkspaceReader(); - var solutionRoots = DiscoverSolutionRoots(workspacePath); - if (solutionRoots.Count == 0) - solutionRoots = new[] { workspacePath }; - - var loaded = solutionRoots.Select(reader.Load).ToList(); - var workspaceColumns = BuildWorkspaceColumnSet(loaded); - foreach (var ws in loaded) - CollectModelFindings(ws, workspaceColumns, results); - - return loaded.Count == 1 - ? loaded[0] - : reader.LoadMany(solutionRoots.Select((path, index) => new SolutionWorkspaceSource(path, index))); + return new XmlWorkspaceReader().LoadMany( + solutionRoots.Select((path, index) => new SolutionWorkspaceSource(path, index))); } catch (Exception ex) { @@ -131,8 +155,6 @@ private static void ValidateFiles( } } - - private static HashSet BuildWorkspaceColumnSet(IEnumerable workspaces) { var columns = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -143,33 +165,7 @@ private static HashSet BuildWorkspaceColumnSet(IEnumerable wo return columns; } - private static void CollectModelFindings(Workspace workspace, HashSet workspaceColumns, List results) - { - foreach (var loadError in workspace.LoadErrors) - { - results.Add(new ValidationResult( - ValidationSeverity.Error, - $"Load error: {loadError.Message}", - loadError.FilePath, - loadError.Line, - loadError.Column) { Stage = ValidationStage.ModelLoad }); - } - - foreach (var diagnostic in workspace.FlowDefinitions.SelectMany(f => f.Diagnostics)) - { - results.Add(new ValidationResult( - MapFlowSeverity(diagnostic.Severity), - $"Flow {diagnostic.Code}: {diagnostic.Message}", - diagnostic.FilePath, - diagnostic.Line, - diagnostic.Column) { Stage = ValidationStage.Flow }); - } - - results.AddRange(WithStage(new RelationshipValidator().Validate(workspace, workspaceColumns), ValidationStage.Relationship)); - results.AddRange(new SolutionManifestValidator().Validate(workspace)); - } - - private static WorkspaceValidationReport BuildReport(IEnumerable results, Workspace? workspace) + internal static WorkspaceValidationReport BuildReport(IEnumerable results, Workspace? workspace) { var labeled = results .Select(r => r with { Message = $"[{r.Stage.Label()}] {r.Message}" }) @@ -191,7 +187,7 @@ private static IReadOnlyList DiscoverSolutionRoots(string workspacePath) var dir = pending.Pop(); foreach (var child in Directory.EnumerateDirectories(dir)) { - if (IgnoredDirectories.Contains(Path.GetFileName(child))) + if (WorkspaceFiles.IgnoredDirectories.Contains(Path.GetFileName(child))) continue; if (File.Exists(Path.Combine(child, "Other", "Solution.xml"))) @@ -203,9 +199,6 @@ private static IReadOnlyList DiscoverSolutionRoots(string workspacePath) return roots; } - - private static IEnumerable WithStage(IEnumerable results, ValidationStage stage) => results.Select(r => r with { Stage = stage }); - private static ValidationSeverity MapFlowSeverity(FlowDiagnosticSeverity severity) => severity == FlowDiagnosticSeverity.Error ? ValidationSeverity.Error : ValidationSeverity.Warning; } /// diff --git a/tests/TALXIS.Platform.Metadata.Tests/SolutionValidatorTests.cs b/tests/TALXIS.Platform.Metadata.Tests/SolutionValidatorTests.cs new file mode 100644 index 0000000..fdb487e --- /dev/null +++ b/tests/TALXIS.Platform.Metadata.Tests/SolutionValidatorTests.cs @@ -0,0 +1,76 @@ +using TALXIS.Platform.Metadata; +using TALXIS.Platform.Metadata.Components; +using TALXIS.Platform.Metadata.Components.Attributes; +using TALXIS.Platform.Metadata.Serialization.Xml; +using TALXIS.Platform.Metadata.Validation; + +namespace TALXIS.Platform.Metadata.Tests; + +public class SolutionValidatorTests +{ + private static readonly string SamplePath = Path.Combine(AppContext.BaseDirectory, "TestData", "SampleWorkspace"); + + [Fact] + public void Validate_NonExistentPath_ReturnsError() + { + var report = new SolutionValidator().Validate("/nonexistent/path/that/does/not/exist"); + + Assert.True(report.ErrorCount > 0); + Assert.Null(report.Workspace); + Assert.Contains(report.Results, r => + r.Severity == ValidationSeverity.Error && r.Message.Contains("Directory not found")); + } + + [Fact] + public void Validate_SampleSolution_LoadsWorkspaceAndSummary() + { + var report = new SolutionValidator().Validate(SamplePath); + + Assert.NotNull(report.Workspace); + Assert.NotNull(report.LoadedComponents); + Assert.True(report.LoadedComponents.Total > 0); + Assert.DoesNotContain(report.Results, r => r.Message.Contains("No Other/Solution.xml")); + } + + [Fact] + public void Validate_MissingSolutionManifest_ReportsWarning() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"sol-test-manifest-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(tempDir); + + var report = new SolutionValidator().Validate(tempDir); + + Assert.Contains(report.Results, r => + r.Severity == ValidationSeverity.Warning && r.Message.Contains("No Other/Solution.xml")); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void RelationshipRules_NotPartOfSolutionValidation() + { + var ws = new Workspace("test"); + var entity = new EntityMetadata { LogicalName = "pba_child" }; + entity.AddAttribute(new LookupAttributeMetadata { LogicalName = "pba_lookup", IsCustomAttribute = true }); + ws.AddEntity(entity); + ws.AddRelationship(new OneToManyRelationshipMetadata + { + SchemaName = "pba_parent_pba_child", + ReferencedEntity = "pba_parent", + ReferencedAttribute = "pba_parentid", + ReferencingEntity = "pba_child", + ReferencingAttribute = "pba_missing", + }); + + var missingDir = Path.Combine(Path.GetTempPath(), $"sol-test-none-{Guid.NewGuid():N}"); + var results = new SolutionValidator().Validate(ws, missingDir).Results; + + Assert.DoesNotContain(results, r => r.Message.Contains("pba_missing")); + Assert.DoesNotContain(results, r => r.Message.Contains("[Relationship]")); + } +} diff --git a/tests/TALXIS.Platform.Metadata.Tests/WorkspaceValidatorTests.cs b/tests/TALXIS.Platform.Metadata.Tests/WorkspaceValidatorTests.cs index e69a1d4..b8b0cb8 100644 --- a/tests/TALXIS.Platform.Metadata.Tests/WorkspaceValidatorTests.cs +++ b/tests/TALXIS.Platform.Metadata.Tests/WorkspaceValidatorTests.cs @@ -179,4 +179,185 @@ public void FlowDiagnostics_SurfacedInReport() if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } } + + [Fact] + public void ValidateDirectory_RelationshipFindings_StillIncluded() + { + var report = new WorkspaceValidator().ValidateDirectory(SamplePath); + + Assert.Contains(report.Results, r => r.Message.Contains("[Relationship]")); + } + + [Fact] + public void ValidateRelationships_ReportsOnlyRelationshipStage() + { + var report = new WorkspaceValidator().ValidateRelationships(SamplePath); + + Assert.Contains(report.Results, r => r.Message.Contains("[Relationship]")); + Assert.DoesNotContain(report.Results, r => r.Message.Contains("[Schema]")); + Assert.DoesNotContain(report.Results, r => r.Message.Contains("[Solution]")); + } + + [Fact] + public void MultiSolution_DuplicateGuidAcrossRoots_DifferentComponents_ReportsError() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-xroot-guid-{Guid.NewGuid():N}"); + try + { + var guid = "{12345678-1234-1234-1234-123456789012}"; + WriteSolution(tempDir, "SolA", ("Entities", "entity_one", "Form1.xml", guid)); + WriteSolution(tempDir, "SolB", ("Entities", "entity_two", "Form2.xml", guid)); + + var report = new WorkspaceValidator().ValidateDirectory(tempDir); + + Assert.Contains(report.Results, r => + r.Severity == ValidationSeverity.Error && r.Message.Contains("Duplicate GUID")); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void MultiSolution_SameComponentInBothRoots_NotFlaggedAsDuplicate() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-xroot-layer-{Guid.NewGuid():N}"); + try + { + var guid = "{12345678-1234-1234-1234-123456789012}"; + WriteSolution(tempDir, "SolA", ("Entities", "shared_entity", "Form1.xml", guid)); + WriteSolution(tempDir, "SolB", ("Entities", "shared_entity", "Form1.xml", guid)); + + var report = new WorkspaceValidator().ValidateDirectory(tempDir); + + Assert.DoesNotContain(report.Results, r => r.Message.Contains("Duplicate GUID")); + Assert.NotNull(report.Workspace); + Assert.NotNull(report.LoadedComponents); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void MultiSolution_OneSolutionFailsToLoad_OthersStillValidated() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-partial-load-{Guid.NewGuid():N}"); + try + { + WriteSolution(tempDir, "SolBroken"); + var brokenEntityDir = Path.Combine(tempDir, "SolBroken", "Entities", "broken_entity"); + Directory.CreateDirectory(brokenEntityDir); + File.WriteAllText(Path.Combine(brokenEntityDir, "Entity.xml"), "", + "")); + + var report = new WorkspaceValidator().ValidateDirectory(tempDir); + + Assert.Contains(report.Results, r => + r.Severity == ValidationSeverity.Error && + r.Message.Contains("Failed to load workspace into model") && + r.FilePath != null && r.FilePath.Contains("SolBroken")); + Assert.Contains(report.Results, r => + r.Severity == ValidationSeverity.Error && + r.Message.Contains("tp_missing_entity")); + Assert.Null(report.Workspace); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void MultiSolution_FileOutsideAnyRoot_StillValidated() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-leftover-{Guid.NewGuid():N}"); + try + { + WriteSolution(tempDir, "SolA"); + File.WriteAllText(Path.Combine(tempDir, "stray.xml"), " + r.Severity == ValidationSeverity.Error && + r.FilePath != null && r.FilePath.EndsWith("stray.xml", StringComparison.Ordinal)); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public void MultiSolution_DuplicateGuidWithinOneRoot_StillReported() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"ws-test-xroot-intra-{Guid.NewGuid():N}"); + try + { + var guid = "{12345678-1234-1234-1234-123456789012}"; + WriteSolution(tempDir, "SolA", + ("Entities", "entity_one", "Form1.xml", guid), + ("Entities", "entity_two", "Form2.xml", guid)); + WriteSolution(tempDir, "SolB", ("Entities", "entity_other", "Form3.xml", "{aaaaaaaa-bbbb-cccc-dddd-eeeeffff0000}")); + + var report = new WorkspaceValidator().ValidateDirectory(tempDir); + + Assert.Contains(report.Results, r => + r.Severity == ValidationSeverity.Error && r.Message.Contains("Duplicate GUID")); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + private static void WriteSolution(string workspaceDir, string uniqueName, params (string Folder, string Entity, string FormFile, string FormId)[] forms) + { + var solutionDir = Path.Combine(workspaceDir, uniqueName); + Directory.CreateDirectory(Path.Combine(solutionDir, "Other")); + File.WriteAllText(Path.Combine(solutionDir, "Other", "Solution.xml"), $""" + + + + {uniqueName} + + + + + 1.0.0.0 + 0 + + TestPub + + + + + + + tp + 10000 + + + + + + + """); + + foreach (var (folder, entity, formFile, formId) in forms) + { + var formDir = Path.Combine(solutionDir, folder, entity, "FormXml", "main"); + Directory.CreateDirectory(formDir); + File.WriteAllText(Path.Combine(formDir, formFile), + $"\n{formId}"); + } + } }