diff --git a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs index 1b474a5..f338d64 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers.CodeFixers/SystemIOAbstractionsCodeFixProvider.cs @@ -93,6 +93,10 @@ await TryRegisterFilesCtorFixAsync(context, diagnostic, node, pattern) await TryRegisterAddDriveFixAsync(context, diagnostic, node) .ConfigureAwait(false); break; + case Patterns.MockFileSystemAddFilesFromEmbeddedNamespace: + await TryRegisterAddFilesFromEmbeddedNamespaceFixAsync(context, diagnostic, node) + .ConfigureAwait(false); + break; } } } @@ -1500,8 +1504,225 @@ private static string PickFreshDriveLambdaParameterName(List null, }; + // ── Pattern: MockFileSystem.AddFilesFromEmbeddedNamespace ──────────────── + + private static async Task TryRegisterAddFilesFromEmbeddedNamespaceFixAsync( + CodeFixContext context, Diagnostic diagnostic, SyntaxNode node) + { + InvocationExpressionSyntax? invocation = node.FirstAncestorOrSelf(); + if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess + || invocation.ArgumentList.Arguments.Count != 3) + { + return; + } + + // The Testably target (`fileSystem.InitializeEmbeddedResourcesFromAssembly(...)`) + // is an extension method on `IFileSystem`, so any `IFileSystem`-implementing + // receiver would in principle bind. We deliberately tighten that to the concrete + // TestableIO `MockFileSystem` via `IsConcreteMockFileSystemReceiver`: it is the + // receiver shape this migration is designed to flag, and it keeps the gate + // consistent with sibling accessor fixes (AddFile, AddDirectory, etc.). The + // `IMockFileDataAccessor` interface in particular does NOT extend `IFileSystem`, + // so the rewritten call would not bind through that path. + SemanticModel? semanticModel = await context.Document + .GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null + || !IsConcreteMockFileSystemReceiver(memberAccess.Expression, semanticModel)) + { + return; + } + + if (!TryComputeRelativePathFromAssemblyAndLiteral( + invocation.ArgumentList.Arguments[1], + invocation.ArgumentList.Arguments[2], + semanticModel, + context.CancellationToken, + out _)) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Resources.TestablyM001CodeFixTitle, + ct => ApplyAddFilesFromEmbeddedNamespaceRewriteAsync(context.Document, diagnostic, ct), + equivalenceKey: Patterns.MockFileSystemAddFilesFromEmbeddedNamespace), + diagnostic); + } + + private static async Task ApplyAddFilesFromEmbeddedNamespaceRewriteAsync( + Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + SyntaxNode? root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is not CompilationUnitSyntax compilationUnit) + { + return document; + } + + SemanticModel? semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return document; + } + + SyntaxNode? node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + InvocationExpressionSyntax? invocation = node?.FirstAncestorOrSelf(); + if (invocation?.Expression is not MemberAccessExpressionSyntax memberAccess + || invocation.ArgumentList.Arguments.Count != 3) + { + return document; + } + + ArgumentSyntax pathArg = invocation.ArgumentList.Arguments[0]; + ArgumentSyntax assemblyArg = invocation.ArgumentList.Arguments[1]; + if (!TryComputeRelativePathFromAssemblyAndLiteral( + assemblyArg, + invocation.ArgumentList.Arguments[2], + semanticModel, + cancellationToken, + out string? relativePath)) + { + return document; + } + + MemberAccessExpressionSyntax newAccess = SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + memberAccess.Expression, + SyntaxFactory.IdentifierName("InitializeEmbeddedResourcesFromAssembly")); + + // Strip NameColon on positional arguments. TestableIO uses `path` / `resourceAssembly` + // while Testably uses `directoryPath` / `assembly` — keeping the labels would not bind. + ArgumentSyntax newPath = pathArg.WithNameColon(null).WithoutTrivia(); + ArgumentSyntax newAssembly = assemblyArg.WithNameColon(null).WithoutTrivia(); + + SeparatedSyntaxList args = SyntaxFactory.SeparatedList( + new[] { newPath, newAssembly, }); + if (relativePath is not null) + { + args = args.Add( + SyntaxFactory.Argument( + SyntaxFactory.NameColon(SyntaxFactory.IdentifierName("relativePath")), + refKindKeyword: default, + expression: SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(relativePath)))); + } + + InvocationExpressionSyntax replacement = SyntaxFactory.InvocationExpression( + newAccess, SyntaxFactory.ArgumentList(args)); + + compilationUnit = compilationUnit.ReplaceNode(invocation, replacement.WithTriviaFrom(invocation)); + compilationUnit = EnsureTestablyUsing(compilationUnit); + return document.WithSyntaxRoot(compilationUnit); + } + + private static bool TryComputeRelativePathFromAssemblyAndLiteral( + ArgumentSyntax assemblyArg, + ArgumentSyntax embeddedResourcePathArg, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out string? relativePath) + { + relativePath = null; + + if (embeddedResourcePathArg.Expression is not LiteralExpressionSyntax literal + || !literal.IsKind(SyntaxKind.StringLiteralExpression)) + { + return false; + } + + string? assemblyName = TryResolveAssemblyName(assemblyArg.Expression, semanticModel, cancellationToken); + if (assemblyName is null) + { + return false; + } + + string literalValue = literal.Token.ValueText; + string prefix = assemblyName + "."; + + // Empty remainder = "literal is exactly the assembly name (with or without trailing + // dot)". Both correspond to "no relativePath filter" in Testably; emit no + // relativePath argument so the call materializes every embedded resource (matching + // TestableIO's `StartsWith()` behavior). + if (literalValue == assemblyName || literalValue == prefix) + { + relativePath = null; + return true; + } + + if (!literalValue.StartsWith(prefix, System.StringComparison.Ordinal)) + { + return false; + } + + string remainder = literalValue.Substring(prefix.Length); + if (remainder.Length == 0) + { + relativePath = null; + return true; + } + + // Forward slash works cross-platform: Testably normalizes + // AltDirectorySeparatorChar to DirectorySeparatorChar before matching. + relativePath = remainder.Replace('.', '/'); + return true; + } + + private static string? TryResolveAssemblyName( + ExpressionSyntax expression, SemanticModel semanticModel, CancellationToken cancellationToken) + { + // Shape 1: `typeof(SomeType).Assembly`. Resolve the type via the semantic model + // and read the containing assembly's name. + if (expression is MemberAccessExpressionSyntax + { + Name.Identifier.Text: "Assembly", + Expression: TypeOfExpressionSyntax typeOf, + }) + { + TypeInfo info = semanticModel.GetTypeInfo(typeOf.Type, cancellationToken); + ITypeSymbol? typeSymbol = info.Type; + IAssemblySymbol? assembly = typeSymbol?.ContainingAssembly; + return assembly?.Name; + } + + // Shape 2: `Assembly.GetExecutingAssembly()` (or any qualified form thereof). The + // executing assembly is the assembly currently being compiled — which is the + // SemanticModel's compilation assembly. Resolve via symbol lookup so we handle + // both `Assembly.GetExecutingAssembly()` and `System.Reflection.Assembly. + // GetExecutingAssembly()` uniformly. (`GetCallingAssembly` cannot be resolved + // statically — its return value depends on the caller frame at runtime.) + if (expression is InvocationExpressionSyntax invocation + && semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol + is IMethodSymbol + { + Name: "GetExecutingAssembly", + Parameters.Length: 0, + ContainingType: + { + Name: "Assembly", + ContainingNamespace: { Name: "Reflection", ContainingNamespace.Name: "System", }, + }, + }) + { + return semanticModel.Compilation.AssemblyName; + } + + return null; + } + // ── Shared: using-directive swap ───────────────────────────────────────── + private static CompilationUnitSyntax EnsureTestablyUsing(CompilationUnitSyntax compilationUnit) + { + if (compilationUnit.Usings.Any(u => u.Name?.ToString() == TestablyTestingNamespace)) + { + return compilationUnit; + } + + UsingDirectiveSyntax usingDirective = BuildUsingDirective(compilationUnit, TestablyTestingNamespace); + return compilationUnit.AddUsings(usingDirective); + } + private static CompilationUnitSyntax SwapToTestablyUsing(CompilationUnitSyntax compilationUnit) { UsingDirectiveSyntax? testingHelpersUsing = compilationUnit.Usings diff --git a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs index 49d16c8..bd31358 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/Patterns.cs @@ -70,6 +70,30 @@ public static class Patterns /// public const string MockFileSystemMockTime = "MockFileSystem.MockTime"; + /// + /// fs.AddFileFromEmbeddedResource(path, assembly, embeddedResourcePath). + /// Manual review (Phase 5.2): Testably exposes only a bulk + /// InitializeEmbeddedResourcesFromAssembly(directoryPath, assembly, + /// relativePath, searchPattern, searchOption) with no single-file overload, + /// and uses a path-style match (auto-strips the assembly-name prefix and + /// replaces dots with directory separators) instead of TestableIO's literal + /// dot-prefix match. A naive textual rewrite would compile but materialize a + /// different resource set, so the call site is reported for manual migration. + /// + public const string MockFileSystemAddFileFromEmbeddedResource = + "MockFileSystem.AddFileFromEmbeddedResource"; + + /// + /// fs.AddFilesFromEmbeddedNamespace(path, assembly, embeddedResourcePath). + /// Phase 5.2: when the assembly arg resolves statically (typeof(X).Assembly + /// or Assembly.GetExecutingAssembly()) and the third arg is a string + /// literal that starts with the resolved assembly name, the code-fix rewrites to + /// Testably's InitializeEmbeddedResourcesFromAssembly. Otherwise the call + /// site is left for manual review. + /// + public const string MockFileSystemAddFilesFromEmbeddedNamespace = + "MockFileSystem.AddFilesFromEmbeddedNamespace"; + // ── Enumeration properties (Phase 5.1) ──────────────────────────────── // These IMockFileDataAccessor properties enumerate the whole mocked file // system. Testably has no direct equivalent — the natural replacements diff --git a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs index 3b7c129..ccd7851 100644 --- a/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs +++ b/Source/Testably.Abstractions.Migration.Analyzers/SystemIOAbstractionsAnalyzer.cs @@ -315,6 +315,8 @@ private static bool IsMockFileSystemOptions(IParameterSymbol parameter) "FileExists" => Patterns.AccessorFileExists, "AddDrive" => Patterns.MockFileSystemAddDrive, "MockTime" => Patterns.MockFileSystemMockTime, + "AddFileFromEmbeddedResource" => Patterns.MockFileSystemAddFileFromEmbeddedResource, + "AddFilesFromEmbeddedNamespace" => Patterns.MockFileSystemAddFilesFromEmbeddedNamespace, _ => null, }; diff --git a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/ManualReviewTests.cs b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/ManualReviewTests.cs index a7f9479..cbb4ee5 100644 --- a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/ManualReviewTests.cs +++ b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/ManualReviewTests.cs @@ -99,6 +99,39 @@ public async Task MockFileSystem_MockTime_ReturnsSelfForFluentChaining() await That(chained).IsSameAs(fs); } + [Fact] + public async Task MockFileSystem_AddFileFromEmbeddedResource_MaterializesEmbeddedFile() + { + // TestableIO matches the resource name literally; Testably exposes only a + // bulk InitializeEmbeddedResourcesFromAssembly with no single-file overload. + // Manual review: pattern id `MockFileSystem.AddFileFromEmbeddedResource`. + MockFileSystem fs = new(); + fs.AddFileFromEmbeddedResource( + "/data/sample.txt", + typeof(ManualReviewTests).Assembly, + "Testably.Abstractions.Migration.SystemIOAbstractionsPlayground.TestData.sample.txt"); + + await That(fs.File.ReadAllText("/data/sample.txt").Trim()) + .IsEqualTo("embedded-resource-content"); + } + + [Fact] + public async Task MockFileSystem_AddFilesFromEmbeddedNamespace_MaterializesMatchingFiles() + { + // TestableIO uses a literal StartsWith on the assembly-qualified resource name, + // dropping the matched prefix + one separator dot to derive each filename. + // The Phase 5.2 code-fix rewrites this to Testably's + // InitializeEmbeddedResourcesFromAssembly when the assembly resolves statically. + MockFileSystem fs = new(); + fs.AddFilesFromEmbeddedNamespace( + "/data", + typeof(ManualReviewTests).Assembly, + "Testably.Abstractions.Migration.SystemIOAbstractionsPlayground.TestData"); + + await That(fs.File.ReadAllText("/data/sample.txt").Trim()) + .IsEqualTo("embedded-resource-content"); + } + private sealed class MyMockFs : MockFileSystem { } diff --git a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/TestData/sample.txt b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/TestData/sample.txt new file mode 100644 index 0000000..8cd1af2 --- /dev/null +++ b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/TestData/sample.txt @@ -0,0 +1 @@ +embedded-resource-content diff --git a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground.csproj b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground.csproj index 074512b..cc0ad59 100644 --- a/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground.csproj +++ b/Tests/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground/Testably.Abstractions.Migration.SystemIOAbstractionsPlayground.csproj @@ -21,4 +21,8 @@ + + + + diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs index 48dddb1..d017f12 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsAnalyzerTests.cs @@ -161,6 +161,44 @@ await Verifier.VerifyAnalyzerAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); } + [Fact] + public async Task AddFileFromEmbeddedResource_ShouldBeFlagged() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + using System.Reflection; + + public class C + { + public void Run(MockFileSystem fs, Assembly asm) + => {|#0:fs.AddFileFromEmbeddedResource("/data/foo.json", asm, "MyAssembly.TestData.foo.json")|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + + [Fact] + public async Task AddFilesFromEmbeddedNamespace_ShouldBeFlagged() + { + const string source = """ + using System.IO.Abstractions.TestingHelpers; + using System.Reflection; + + public class C + { + public void Run(MockFileSystem fs, Assembly asm) + => {|#0:fs.AddFilesFromEmbeddedNamespace("/data", asm, "MyAssembly.TestData")|}; + } + """; + + await Verifier.VerifyAnalyzerAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0)); + } + [Theory] [InlineData("AllPaths")] [InlineData("AllFiles")] diff --git a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs index 23ec82c..d2b3c93 100644 --- a/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs +++ b/Tests/Testably.Abstractions.Migration.Tests/SystemIOAbstractionsCodeFixProviderTests.AccessorMethodTests.cs @@ -738,5 +738,256 @@ await Verifier.VerifyCodeFixAsync( Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), source); } + + [Fact] + public async Task AddFileFromEmbeddedResource_HasNoFix() + { + // Testably exposes only a bulk InitializeEmbeddedResourcesFromAssembly with no + // single-file overload, and uses path-style matching against the auto-stripped + // resource name rather than TestableIO's literal dot-prefix StartsWith. A naive + // rewrite would compile but materialize a different resource set, so the call + // site is reported for manual migration. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + using System.Reflection; + + public class C + { + public void Run(MockFileSystem fs, Assembly asm) + => {|#0:fs.AddFileFromEmbeddedResource("/data/foo.json", asm, "MyAssembly.TestData.foo.json")|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AddFilesFromEmbeddedNamespace_NonResolvableAssembly_HasNoFix() + { + // When the assembly arg is an opaque parameter, the analyzer cannot identify + // which prefix to strip from the literal — fix dispatcher falls through to + // manual review. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + using System.Reflection; + + public class C + { + public void Run(MockFileSystem fs, Assembly asm) + => {|#0:fs.AddFilesFromEmbeddedNamespace("/data", asm, "MyAssembly.TestData")|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AddFilesFromEmbeddedNamespace_TypeOfAssembly_RewritesToInitializeEmbeddedResources() + { + // When the assembly arg is `typeof(X).Assembly` and the literal starts with + // the resolved assembly name, strip the prefix and emit `relativePath:`. + // Defaults aside, the rewrite uses Testably's + // `InitializeEmbeddedResourcesFromAssembly` extension, so a `using + // Testably.Abstractions.Testing;` is added without disturbing the existing + // TestingHelpers using (the receiver type stays bound to TestableIO so + // other call sites in the file keep compiling). + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddFilesFromEmbeddedNamespace("/data", typeof(C).Assembly, "TestProject.TestData")|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + using Testably.Abstractions.Testing; + + public class C + { + public void Run(MockFileSystem fs) + => fs.InitializeEmbeddedResourcesFromAssembly("/data", typeof(C).Assembly, relativePath: "TestData"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AddFilesFromEmbeddedNamespace_NestedNamespace_PreservesPath() + { + // Multi-segment relative path: dots are converted to forward slashes so + // Testably's path-style `relativePath` matcher consumes them correctly. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddFilesFromEmbeddedNamespace("/data", typeof(C).Assembly, "TestProject.TestData.Sub.Inner")|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + using Testably.Abstractions.Testing; + + public class C + { + public void Run(MockFileSystem fs) + => fs.InitializeEmbeddedResourcesFromAssembly("/data", typeof(C).Assembly, relativePath: "TestData/Sub/Inner"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AddFilesFromEmbeddedNamespace_LiteralEqualsAssemblyName_OmitsRelativePath() + { + // Literal == assembly name (or assembly-name + ".") means "all resources" in + // TestableIO. Equivalent in Testably is calling without `relativePath`, so the + // rewrite drops that argument entirely. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddFilesFromEmbeddedNamespace("/data", typeof(C).Assembly, "TestProject")|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + using Testably.Abstractions.Testing; + + public class C + { + public void Run(MockFileSystem fs) + => fs.InitializeEmbeddedResourcesFromAssembly("/data", typeof(C).Assembly); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AddFilesFromEmbeddedNamespace_GetExecutingAssembly_RewritesToInitializeEmbeddedResources() + { + // `Assembly.GetExecutingAssembly()` resolves to the compilation's own + // assembly. The analyzer reads `Compilation.AssemblyName` and strips the + // prefix the same way it does for `typeof(X).Assembly`. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + using System.Reflection; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddFilesFromEmbeddedNamespace("/data", Assembly.GetExecutingAssembly(), "TestProject.TestData")|}; + } + """; + + const string fixedSource = """ + using System.IO.Abstractions.TestingHelpers; + using System.Reflection; + using Testably.Abstractions.Testing; + + public class C + { + public void Run(MockFileSystem fs) + => fs.InitializeEmbeddedResourcesFromAssembly("/data", Assembly.GetExecutingAssembly(), relativePath: "TestData"); + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + fixedSource); + } + + [Fact] + public async Task AddFilesFromEmbeddedNamespace_LiteralPrefixMismatch_HasNoFix() + { + // Literal does not start with the resolved assembly name. The user might be + // reaching into a different assembly's resource graph; without static + // confirmation that the prefix is correct, the analyzer cannot strip safely. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs) + => {|#0:fs.AddFilesFromEmbeddedNamespace("/data", typeof(C).Assembly, "OtherAssembly.TestData")|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AddFilesFromEmbeddedNamespace_NonLiteralPath_HasNoFix() + { + // The third argument is not a string literal — could be a const reference, + // concatenation, or any expression. The analyzer can't strip a prefix it + // can't read, so manual review. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(MockFileSystem fs, string ns) + => {|#0:fs.AddFilesFromEmbeddedNamespace("/data", typeof(C).Assembly, ns)|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } + + [Fact] + public async Task AddFilesFromEmbeddedNamespace_InterfaceTypedReceiver_HasNoFix() + { + // The Testably target is an extension method on `IFileSystem`. The TestableIO + // `IMockFileDataAccessor` interface does NOT implement `IFileSystem`, so the + // rewritten call would not bind. Fall through to manual review. + const string source = """ + using System.IO.Abstractions.TestingHelpers; + + public class C + { + public void Run(IMockFileDataAccessor accessor) + => {|#0:accessor.AddFilesFromEmbeddedNamespace("/data", typeof(C).Assembly, "TestProject.TestData")|}; + } + """; + + await Verifier.VerifyCodeFixAsync( + source, + Verifier.Diagnostic(Rules.SystemIOAbstractionsRule).WithLocation(0), + source); + } } }