diff --git a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs index 611ef98..1db5a3e 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs @@ -57,6 +57,27 @@ public static class Patterns /// public const string MockFileSystemAddDrive = "MockFileSystem.AddDrive"; + // ── Enumeration properties (Phase 5.1) ──────────────────────────────── + // These IMockFileDataAccessor properties enumerate the whole mocked file + // system. Testably has no direct equivalent — the natural replacements + // (Directory.EnumerateFiles/EnumerateDirectories, DriveInfo.GetDrives, + // etc.) need a root path or drive scope the analyzer cannot infer safely. + // Each property gets its own pattern id so manual migration is + // discoverable per call site; the code-fix provider intentionally + // registers no rewrite. + + /// fs.AllPaths — union of files and directories across the mocked file system. + public const string MockFileSystemAllPaths = "MockFileSystem.AllPaths"; + + /// fs.AllFiles — every mocked file path. + public const string MockFileSystemAllFiles = "MockFileSystem.AllFiles"; + + /// fs.AllDirectories — every mocked directory path. + public const string MockFileSystemAllDirectories = "MockFileSystem.AllDirectories"; + + /// fs.AllDrives — every mocked drive name. + public const string MockFileSystemAllDrives = "MockFileSystem.AllDrives"; + // ── MockFileData property access ────────────────────────────────────── /// A read access to a MockFileData property. diff --git a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs index 055731a..63932a5 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs @@ -179,13 +179,35 @@ symbols.MockFileDataAccessor is { } accessor private static void AnalyzePropertyReference(OperationAnalysisContext context, TestableIoSymbols symbols) { - if (symbols.MockFileData is null - || context.Operation is not IPropertyReferenceOperation propertyRef) + if (context.Operation is not IPropertyReferenceOperation propertyRef) { return; } - if (!SymbolEqualityComparer.Default.Equals(propertyRef.Property.ContainingType, symbols.MockFileData)) + INamedTypeSymbol? containingType = propertyRef.Property.ContainingType; + if (containingType is null) + { + return; + } + + // Phase 5.1: IMockFileDataAccessor enumeration properties (AllPaths, + // AllFiles, AllDirectories, AllDrives). These have no 1:1 Testably + // equivalent — manual migration only. + bool onMockFileSystem = SymbolEqualityComparer.Default.Equals(containingType, symbols.MockFileSystem); + bool onAccessor = symbols.MockFileDataAccessor is { } accessor + && SymbolEqualityComparer.Default.Equals(containingType, accessor); + if (onMockFileSystem || onAccessor) + { + string? enumerationPattern = ClassifyEnumerationProperty(propertyRef.Property.Name); + if (enumerationPattern is not null) + { + Report(context, propertyRef.Syntax.GetLocation(), enumerationPattern); + return; + } + } + + if (symbols.MockFileData is null + || !SymbolEqualityComparer.Default.Equals(containingType, symbols.MockFileData)) { return; } @@ -249,6 +271,15 @@ private static void AnalyzePropertyReference(OperationAnalysisContext context, T _ => null, }; + private static string? ClassifyEnumerationProperty(string propertyName) => propertyName switch + { + "AllPaths" => Patterns.MockFileSystemAllPaths, + "AllFiles" => Patterns.MockFileSystemAllFiles, + "AllDirectories" => Patterns.MockFileSystemAllDirectories, + "AllDrives" => Patterns.MockFileSystemAllDrives, + _ => null, + }; + private static string? ClassifyMockFileSystemConstructor(IMethodSymbol constructor) { ImmutableArray parameters = constructor.Parameters; diff --git a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs index bebe718..e62fafc 100644 --- a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs +++ b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/AccessorMethodTests.cs @@ -135,6 +135,48 @@ public async Task AddDrive_WithTotalSize_RegistersDriveWithSize() await That(drive!.TotalSize).IsEqualTo(totalSize); } + [Fact] + public async Task AllFiles_EnumeratesEveryAddedFile() + { + // Phase 5.1 manual-review fixture: Testably has no AllFiles equivalent. The + // migration target depends on the user's drive layout — Directory.EnumerateFiles + // against the right root, or DriveInfo.GetDrives() + SelectMany for multi-drive + // setups. The playground keeps the parity baseline so a human can decide. + MockFileSystem fs = new(); + fs.AddFile("/a/one.txt", new MockFileData("1")); + fs.AddFile("/b/two.txt", new MockFileData("2")); + + bool sawOne = false; + bool sawTwo = false; + foreach (string path in fs.AllFiles) + { + sawOne |= path.EndsWith("one.txt", StringComparison.Ordinal); + sawTwo |= path.EndsWith("two.txt", StringComparison.Ordinal); + } + + await That(sawOne).IsTrue(); + await That(sawTwo).IsTrue(); + } + + [Fact] + public async Task AllDirectories_EnumeratesEveryAddedDirectory() + { + MockFileSystem fs = new(); + fs.AddDirectory("/a/x"); + fs.AddDirectory("/b/y"); + + bool sawX = false; + bool sawY = false; + foreach (string path in fs.AllDirectories) + { + sawX |= path.EndsWith("x", StringComparison.Ordinal); + sawY |= path.EndsWith("y", StringComparison.Ordinal); + } + + await That(sawX).IsTrue(); + await That(sawY).IsTrue(); + } + private static IDriveInfo? FindDriveByName(IDriveInfo[] drives, string prefix) { foreach (IDriveInfo drive in drives) diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs index 959a2cc..64d965d 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs @@ -142,6 +142,49 @@ await Verifier.VerifyAnalyzerAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); } + [Theory] + [InlineData("AllPaths")] + [InlineData("AllFiles")] + [InlineData("AllDirectories")] + [InlineData("AllDrives")] + public async Task EnumerationProperty_OnMockFileSystem_ShouldBeFlagged(string property) + { + string source = $$""" + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IEnumerable Read(MockFileSystem fs) => {|#0:fs.{{property}}|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + + [Fact] + public async Task EnumerationProperty_OnAccessorInterface_ShouldBeFlagged() + { + // AllFiles is declared on IMockFileDataAccessor — when the receiver is the + // interface type itself, the property symbol's containing type points at the + // interface, which the analyzer must still recognise. + const string source = """ + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IEnumerable Read(IMockFileDataAccessor accessor) => {|#0:accessor.AllFiles|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + [Theory] [InlineData("TextContents")] [InlineData("Contents")] diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs index 5962e04..c1d8540 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs @@ -678,5 +678,32 @@ await Verifier.VerifyCodeFixAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), source); } + + [Theory] + [InlineData("AllPaths")] + [InlineData("AllFiles")] + [InlineData("AllDirectories")] + [InlineData("AllDrives")] + public async Task EnumerationProperty_HasNoFix(string property) + { + // Testably has no 1:1 equivalent for the IMockFileDataAccessor enumeration + // properties. The natural replacements (Directory.EnumerateFiles, etc.) + // require a root path or drive scope the analyzer cannot infer safely, so + // the fix dispatcher intentionally falls through with no rewrite. + string source = $$""" + using System.Collections.Generic; + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public IEnumerable Read(MockFileSystem fs) => {|#0:fs.{{property}}|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } } }