Skip to content

Commit f75aa55

Browse files
authored
feat: Add extension to IFileSystem to allow using pattern to delete files / directories (#41)
* Add DisposableDirectory and unit tests * Add DisposableFile and unit tests * Add CreateDisposableDirectory extension method * Add CreateDisposableFile extension method * Add example usage in README * Do attribute refresh on Dispose to keep object state in sync between RAII and filesystem object * Make DisposableFile / DisposableDirectory internal, return an IDisposable from extension method instead, and add InternalsVisibleTo in Directory.Build.props * Extract random temp path generation into helper method * Create a base `DisposableFileSystemInfo` class to DRY out Dispose pattern * Move random temp path extension to `IPath` * Move the Refresh call into DisposableFileSystemInfoBase to DRY out code * Make IDisposable implementations public and provide custom factory * Suppress Codacity warning for out param * Fix README example * Fix xmldocs for overloads
1 parent 55a2584 commit f75aa55

File tree

8 files changed

+538
-1
lines changed

8 files changed

+538
-1
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,34 @@ using (var stream = current.File("test.txt").Create())
4949
//create a "test.txt" file without extension
5050
using (var stream = current.FileSystem.FileInfo.FromFileName(current.FileSystem.Path.Combine(current.FullName, "test.txt")).Create())
5151
stream.Dispose();
52+
```
53+
54+
## Automatic cleanup with Disposable extensions
55+
56+
Use `CreateDisposableDirectory` or `CreateDisposableFile` to create a `IDirectoryInfo` or `IFileInfo` that's automatically
57+
deleted when the returned `IDisposable` is disposed.
58+
59+
```csharp
60+
var fs = new FileSystem();
61+
62+
//with extension
63+
using (fs.CreateDisposableDirectory(out IDirectoryInfo dir))
64+
{
65+
Console.WriteLine($"This directory will be deleted when control leaves the using block: '{dir.FullName}'");
66+
}
67+
68+
//without extension
69+
var temp = fs.Path.GetTempPath();
70+
var fileName = fs.Path.GetRandomFileName();
71+
var path = fs.Path.Combine(temp, fileName);
72+
73+
try
74+
{
75+
IDirectoryInfo dir = fs.Directory.CreateDirectory(path);
76+
Console.WriteLine($"This directory will be deleted in the finally block: '{dir.FullName}'");
77+
}
78+
finally
79+
{
80+
fs.Directory.Delete(path, recursive: true);
81+
}
5282
```
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace System.IO.Abstractions.Extensions
2+
{
3+
/// <summary>
4+
/// Creates a class that wraps a <see cref="IDirectoryInfo"/>. That wrapped directory will be
5+
/// deleted when the <see cref="Dispose()"/> method is called.
6+
/// </summary>
7+
/// <inheritdoc/>
8+
public class DisposableDirectory : DisposableFileSystemInfo<IDirectoryInfo>
9+
{
10+
/// <summary>
11+
/// Initializes a new instance of the <see cref="DisposableDirectory"/> class.
12+
/// </summary>
13+
/// <param name="directoryInfo">
14+
/// The directory to delete when this object is disposed.
15+
/// </param>
16+
public DisposableDirectory(IDirectoryInfo directoryInfo) : base(directoryInfo)
17+
{
18+
}
19+
20+
/// <inheritdoc/>
21+
protected override void DeleteFileSystemInfo()
22+
{
23+
fileSystemInfo.Delete(recursive: true);
24+
}
25+
}
26+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
namespace System.IO.Abstractions.Extensions
2+
{
3+
/// <summary>
4+
/// Creates a class that wraps a <see cref="IFileInfo"/>. That wrapped file will be
5+
/// deleted when the <see cref="Dispose()"/> method is called.
6+
/// </summary>
7+
/// <inheritdoc/>
8+
public class DisposableFile : DisposableFileSystemInfo<IFileInfo>
9+
{
10+
/// <summary>
11+
/// Initializes a new instance of the <see cref="DisposableFile"/> class.
12+
/// </summary>
13+
/// <param name="fileInfo">
14+
/// The file to delete when this object is disposed.
15+
/// </param>
16+
public DisposableFile(IFileInfo fileInfo) : base(fileInfo)
17+
{
18+
}
19+
}
20+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
namespace System.IO.Abstractions.Extensions
2+
{
3+
/// <summary>
4+
/// Creates a class that wraps a <see cref="IFileSystemInfo"/>. That wrapped object will be deleted
5+
/// when the <see cref="Dispose()"/> method is called.
6+
/// </summary>
7+
/// <remarks>
8+
/// This class is designed for the <c>using</c> pattern to ensure that a directory is
9+
/// created and then deleted when it is no longer referenced. This is sometimes called
10+
/// the RAII pattern (see https://en.wikipedia.org/wiki/Resource_acquisition_is_initialization).
11+
/// </remarks>
12+
public class DisposableFileSystemInfo<T> : IDisposable where T : IFileSystemInfo
13+
{
14+
protected T fileSystemInfo;
15+
private bool isDisposed;
16+
17+
/// <summary>
18+
/// Initializes a new instance of the <see cref="DisposableFileSystemInfoBase"/> class.
19+
/// </summary>
20+
/// <param name="fileSystemInfo">
21+
/// The directory to delete when this object is disposed.
22+
/// </param>
23+
public DisposableFileSystemInfo(T fileSystemInfo)
24+
{
25+
this.fileSystemInfo = fileSystemInfo ?? throw new ArgumentNullException(nameof(fileSystemInfo));
26+
27+
// Do an attribute refresh so that the object we return to the caller
28+
// has up-to-date properties (like Exists).
29+
this.fileSystemInfo.Refresh();
30+
}
31+
32+
/// <summary>
33+
/// Performs the actual work of releasing resources. This allows for subclasses to participate
34+
/// in resource release.
35+
/// </summary>
36+
/// <param name="disposing">
37+
/// <c>true</c> if if the call comes from a <see cref="Dispose()"/> method, <c>false</c> if it
38+
/// comes from a finalizer.
39+
/// </param>
40+
protected virtual void Dispose(bool disposing)
41+
{
42+
if (!isDisposed)
43+
{
44+
if (disposing)
45+
{
46+
DeleteFileSystemInfo();
47+
48+
// Do an attribute refresh so that the object we returned to the
49+
// caller has up-to-date properties (like Exists).
50+
fileSystemInfo.Refresh();
51+
}
52+
53+
isDisposed = true;
54+
}
55+
}
56+
57+
/// <inheritdoc/>
58+
public void Dispose()
59+
{
60+
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method.
61+
// See https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose.
62+
Dispose(disposing: true);
63+
GC.SuppressFinalize(this);
64+
}
65+
66+
/// <summary>
67+
/// Deletes the wrapped <typeparamref name="T"/> object.
68+
/// </summary>
69+
/// <remarks>
70+
/// Different types of <typeparamref name="T"/> objects have different ways of deleting themselves (e.g.
71+
/// directories usually need to be deleted recursively). This method is called by the <see cref="Dispose()"/>
72+
/// </remarks>
73+
protected virtual void DeleteFileSystemInfo()
74+
{
75+
fileSystemInfo.Delete();
76+
}
77+
}
78+
}

src/System.IO.Abstractions.Extensions/IFileSystemExtensions.cs

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
namespace System.IO.Abstractions.Extensions
1+
// S3874: "out" and "ref" parameters should not be used
2+
// https://rules.sonarsource.com/csharp/RSPEC-3874/
3+
//
4+
// Our CreateDisposableDirectory / CreateDisposableFile extensions
5+
// intentionally use an out param so that the DirectoryInfo / FileInfo
6+
// is passed out (similar to a Try* method) to the caller while the
7+
// returned object implements IDisposable to leverage the using statement.
8+
#pragma warning disable S3874
9+
10+
namespace System.IO.Abstractions.Extensions
211
{
312
public static class IFileSystemExtensions
413
{
@@ -11,5 +20,147 @@ public static IDirectoryInfo CurrentDirectory(this IFileSystem fileSystem)
1120
{
1221
return fileSystem.DirectoryInfo.New(fileSystem.Directory.GetCurrentDirectory());
1322
}
23+
24+
/// <summary>
25+
/// Creates a new <see cref="IDirectoryInfo"/> using a random name from the temp path, and returns an <see cref="IDisposable"/>
26+
/// that deletes the directory when disposed.
27+
/// </summary>
28+
/// <param name="fileSystem">
29+
/// The <see cref="IFileSystem"/> in use.
30+
/// </param>
31+
/// <param name="directoryInfo">
32+
/// The <see cref="IDirectoryInfo"/> for the directory that was created.
33+
/// </param>
34+
/// <returns>
35+
/// An <see cref="IDisposable"/> to manage the directory's lifetime.
36+
/// </returns>
37+
public static IDisposable CreateDisposableDirectory(this IFileSystem fileSystem, out IDirectoryInfo directoryInfo)
38+
{
39+
return fileSystem.CreateDisposableDirectory(fileSystem.Path.GetRandomTempPath(), out directoryInfo);
40+
}
41+
42+
/// <inheritdoc cref="CreateDisposableDirectory(IFileSystem, out IDirectoryInfo)"/>
43+
/// <summary>
44+
/// Creates a new <see cref="IDirectoryInfo"/> using a path provided by <paramref name="path"/>, and returns an
45+
/// <see cref="IDisposable"/> that deletes the directory when disposed.
46+
/// </summary>
47+
/// <param name="path">
48+
/// The full path to the directory to create.
49+
/// </param>
50+
/// <exception cref="ArgumentException">
51+
/// If the directory already exists.
52+
/// </exception>
53+
public static IDisposable CreateDisposableDirectory(this IFileSystem fileSystem, string path, out IDirectoryInfo directoryInfo)
54+
{
55+
return fileSystem.CreateDisposableDirectory(path, dir => new DisposableDirectory(dir), out directoryInfo);
56+
}
57+
58+
/// <inheritdoc cref="CreateDisposableDirectory(IFileSystem, string, out IDirectoryInfo)"/>
59+
/// <summary>
60+
/// Creates a new <see cref="IDirectoryInfo"/> using a path provided by <paramref name="path"/>, and returns an
61+
/// <see cref="IDisposable"/> created by <paramref name="disposableFactory"/>, that should delete the directory when disposed.
62+
/// </summary>
63+
/// <param name="disposableFactory">
64+
/// A <see cref="Func{T, TResult}"/> that acts as a factory method. Given the <see cref="IDirectoryInfo"/>, create the
65+
/// <see cref="IDisposable"/> that will manage the its lifetime.
66+
/// </param>
67+
public static T CreateDisposableDirectory<T>(
68+
this IFileSystem fileSystem,
69+
string path,
70+
Func<IDirectoryInfo, T> disposableFactory,
71+
out IDirectoryInfo directoryInfo) where T : IDisposable
72+
{
73+
directoryInfo = fileSystem.DirectoryInfo.New(path);
74+
75+
if (directoryInfo.Exists)
76+
{
77+
throw CreateAlreadyExistsException(nameof(path), path);
78+
}
79+
80+
directoryInfo.Create();
81+
82+
return disposableFactory(directoryInfo);
83+
}
84+
85+
/// <summary>
86+
/// Creates a new <see cref="IFileInfo"/> using a random name from the temp path, and returns an <see cref="IDisposable"/>
87+
/// that deletes the file when disposed.
88+
/// </summary>
89+
/// <param name="fileSystem">
90+
/// The <see cref="IFileSystem"/> in use.
91+
/// </param>
92+
/// <param name="fileInfo">
93+
/// The <see cref="IFileInfo"/> for the file that was created.
94+
/// </param>
95+
/// <returns>
96+
/// An <see cref="IDisposable"/> to manage the file's lifetime.
97+
/// </returns>
98+
public static IDisposable CreateDisposableFile(this IFileSystem fileSystem, out IFileInfo fileInfo)
99+
{
100+
return fileSystem.CreateDisposableFile(fileSystem.Path.GetRandomTempPath(), out fileInfo);
101+
}
102+
103+
/// <inheritdoc cref="CreateDisposableFile(IFileSystem, out IFileInfo)"/>
104+
/// <summary>
105+
/// Creates a new <see cref="IFileInfo"/> using a path provided by <paramref name="path"/>, and returns an
106+
/// <see cref="IDisposable"/> that deletes the file when disposed.
107+
/// </summary>
108+
/// <param name="path">
109+
/// The full path to the file to create.
110+
/// </param>
111+
/// <exception cref="ArgumentException">
112+
/// If the file already exists.
113+
/// </exception>
114+
public static IDisposable CreateDisposableFile(this IFileSystem fileSystem, string path, out IFileInfo fileInfo)
115+
{
116+
return fileSystem.CreateDisposableFile(path, file => new DisposableFile(file), out fileInfo);
117+
}
118+
119+
/// <inheritdoc cref="CreateDisposableFile(IFileSystem, string, out IFileInfo)"/>
120+
/// <summary>
121+
/// Creates a new <see cref="IFileInfo"/> using a path provided by <paramref name="path"/>, and returns an
122+
/// <see cref="IDisposable"/> created by <paramref name="disposableFactory"/>, that should delete the file when disposed.
123+
/// </summary>
124+
/// <param name="disposableFactory">
125+
/// A <see cref="Func{T, TResult}"/> that acts as a factory method. Given the <see cref="IFileInfo"/>, create the
126+
/// <see cref="IDisposable"/> that will manage the its lifetime.
127+
/// </param>
128+
public static T CreateDisposableFile<T>(
129+
this IFileSystem fileSystem,
130+
string path,
131+
Func<IFileInfo, T> disposableFactory,
132+
out IFileInfo fileInfo) where T : IDisposable
133+
{
134+
fileInfo = fileSystem.FileInfo.New(path);
135+
136+
if (fileInfo.Exists)
137+
{
138+
throw CreateAlreadyExistsException(nameof(path), path);
139+
}
140+
141+
// Ensure we close the handle to the file after we create it, otherwise
142+
// callers may get an access denied error.
143+
fileInfo.Create().Dispose();
144+
145+
return disposableFactory(fileInfo);
146+
}
147+
148+
private static string GetRandomTempPath(this IPath path)
149+
{
150+
var temp = path.GetTempPath();
151+
var fileName = path.GetRandomFileName();
152+
return path.Combine(temp, fileName);
153+
}
154+
155+
private static ArgumentException CreateAlreadyExistsException(string argumentName, string path)
156+
{
157+
// Having the colliding path availabe as part of the exception is very useful for debugging.
158+
// However, paths can be considered sensitive information in some contexts (like web servers).
159+
// Thus, we add the path to the exception's data , rather than the message.
160+
var ex = new ArgumentException("File already exists", argumentName);
161+
ex.Data.Add("path", path);
162+
163+
return ex;
164+
}
14165
}
15166
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using NUnit.Framework;
2+
3+
namespace System.IO.Abstractions.Extensions.Tests
4+
{
5+
[TestFixture]
6+
public class DisposableDirectoryTests
7+
{
8+
[Test]
9+
public void DisposableDirectory_Throws_ArgumentNullException_For_Null_IDirectoryInfo_Test()
10+
{
11+
Assert.Throws<ArgumentNullException>(() => new DisposableDirectory(null!));
12+
}
13+
14+
[Test]
15+
public void DisposableDirectory_DeleteRecursive_On_Dispose_Test()
16+
{
17+
// Arrange
18+
var fs = new FileSystem();
19+
var path = fs.Path.Combine(fs.Directory.GetCurrentDirectory(), fs.Path.GetRandomFileName());
20+
var dirInfo = fs.DirectoryInfo.New(path);
21+
22+
// Create a subdirectory to ensure recursive delete
23+
dirInfo.CreateSubdirectory(Guid.NewGuid().ToString());
24+
25+
// Assert directory exists
26+
Assert.IsTrue(fs.Directory.Exists(path), "Directory should exist");
27+
Assert.IsTrue(dirInfo.Exists, "IDirectoryInfo.Exists should be true");
28+
29+
// Act
30+
var disposableDirectory = new DisposableDirectory(dirInfo);
31+
disposableDirectory.Dispose();
32+
33+
// Assert directory is deleted
34+
Assert.IsFalse(fs.Directory.Exists(path), "Directory should not exist");
35+
Assert.IsFalse(dirInfo.Exists, "IDirectoryInfo.Exists should be false");
36+
37+
// Assert a second dispose does not throw
38+
Assert.DoesNotThrow(() => disposableDirectory.Dispose());
39+
}
40+
}
41+
}

0 commit comments

Comments
 (0)