diff --git a/Directory.Packages.props b/Directory.Packages.props index 2edea4a..60dd745 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,7 @@ - + diff --git a/PowerKit.Tests/DirectoryExtensionsTests.cs b/PowerKit.Tests/DirectoryExtensionsTests.cs index ae0f145..b8d0db1 100644 --- a/PowerKit.Tests/DirectoryExtensionsTests.cs +++ b/PowerKit.Tests/DirectoryExtensionsTests.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Runtime.Versioning; using FluentAssertions; using PowerKit; using PowerKit.Extensions; @@ -9,6 +10,124 @@ namespace PowerKit.Tests; public class DirectoryExtensionsTests { + [Fact] + public void Copy_Test() + { + // Arrange + using var sourceDirectory = TempDirectory.Create(); + using var destinationDirectory = TempDirectory.Create(); + + File.WriteAllText(Path.Combine(sourceDirectory.Path, "file.txt"), "hello"); + + // Act + Directory.Copy(sourceDirectory.Path, destinationDirectory.Path); + + // Assert + File.ReadAllText(Path.Combine(destinationDirectory.Path, "file.txt")).Should().Be("hello"); + } + + [Fact] + public void Copy_Nested_Test() + { + // Arrange + using var sourceDirectory = TempDirectory.Create(); + using var destinationDirectory = TempDirectory.Create(); + + Directory.CreateDirectory(Path.Combine(sourceDirectory.Path, "sub")); + File.WriteAllText(Path.Combine(sourceDirectory.Path, "sub", "file.txt"), "nested"); + + // Act + Directory.Copy(sourceDirectory.Path, destinationDirectory.Path); + + // Assert + File.ReadAllText(Path.Combine(destinationDirectory.Path, "sub", "file.txt")) + .Should() + .Be("nested"); + } + + [Fact] + public void Copy_Overwrite_Test() + { + // Arrange + using var sourceDirectory = TempDirectory.Create(); + using var destinationDirectory = TempDirectory.Create(); + + File.WriteAllText(Path.Combine(sourceDirectory.Path, "file.txt"), "new"); + File.WriteAllText(Path.Combine(destinationDirectory.Path, "file.txt"), "old"); + + // Act + Directory.Copy(sourceDirectory.Path, destinationDirectory.Path, true); + + // Assert + File.ReadAllText(Path.Combine(destinationDirectory.Path, "file.txt")).Should().Be("new"); + } + + [Fact] + public void Copy_NoOverwrite_Test() + { + // Arrange + using var sourceDirectory = TempDirectory.Create(); + using var destinationDirectory = TempDirectory.Create(); + + File.WriteAllText(Path.Combine(sourceDirectory.Path, "file.txt"), "source"); + File.WriteAllText(Path.Combine(destinationDirectory.Path, "file.txt"), "existing"); + + // Act + var act = () => Directory.Copy(sourceDirectory.Path, destinationDirectory.Path, false); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Copy_Truncates_Test() + { + // Arrange + using var sourceDirectory = TempDirectory.Create(); + using var destinationDirectory = TempDirectory.Create(); + + File.WriteAllText(Path.Combine(sourceDirectory.Path, "file.txt"), "hi"); + File.WriteAllText(Path.Combine(destinationDirectory.Path, "file.txt"), "longer content"); + + // Act + Directory.Copy(sourceDirectory.Path, destinationDirectory.Path, true); + + // Assert + File.ReadAllText(Path.Combine(destinationDirectory.Path, "file.txt")).Should().Be("hi"); + } + + [SkippableFact] + [UnsupportedOSPlatform("windows")] + public void Copy_UnixFileMode_Test() + { + Skip.If(OperatingSystem.IsWindows()); + + // Arrange + using var sourceDirectory = TempDirectory.Create(); + using var destinationDirectory = TempDirectory.Create(); + + var sourceFilePath = Path.Combine(sourceDirectory.Path, "file.sh"); + File.WriteAllText(sourceFilePath, "#!/bin/sh"); + File.SetUnixFileMode( + sourceFilePath, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.UserExecute + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute + ); + + // Act + Directory.Copy(sourceDirectory.Path, destinationDirectory.Path); + + // Assert + File.GetUnixFileMode(Path.Combine(destinationDirectory.Path, "file.sh")) + .Should() + .Be(File.GetUnixFileMode(sourceFilePath)); + } + [Fact] public void CheckWriteAccess_Test() { diff --git a/PowerKit/Extensions/DirectoryExtensions.cs b/PowerKit/Extensions/DirectoryExtensions.cs index c9d793f..9e40b9f 100644 --- a/PowerKit/Extensions/DirectoryExtensions.cs +++ b/PowerKit/Extensions/DirectoryExtensions.cs @@ -1,7 +1,9 @@ #nullable enable using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; namespace PowerKit.Extensions; @@ -43,6 +45,94 @@ public static bool TryDelete(string path, bool recursive = false) } } + /// + /// Recursively copies all files from to . + /// Destination files are opened with exclusive locks before any data is written. + /// Concurrent readers may be blocked or fail with a sharing violation while a file is being updated, + /// and this method does not guarantee atomic old-or-new visibility to readers. + /// + public static void Copy(string sourcePath, string destinationPath, bool overwrite = true) + { + var sourceStreams = new List(); + var destinationStreams = new List(); + + try + { + // Create all destination directories + Directory.CreateDirectory(destinationPath); + foreach ( + var sourceDirectoryPath in Directory.GetDirectories( + sourcePath, + "*", + SearchOption.AllDirectories + ) + ) + { + Directory.CreateDirectory( + Path.Combine( + destinationPath, + Path.GetRelativePath(sourcePath, sourceDirectoryPath) + ) + ); + } + + // Create file stream pairs + foreach ( + var sourceFilePath in Directory.GetFiles( + sourcePath, + "*", + SearchOption.AllDirectories + ) + ) + { + sourceStreams.Add(File.OpenRead(sourceFilePath)); + + var destinationFilePath = Path.Combine( + destinationPath, + Path.GetRelativePath(sourcePath, sourceFilePath) + ); + + destinationStreams.Add( + overwrite + ? File.OpenWrite(destinationFilePath) + : File.Open( + destinationFilePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None + ) + ); + } + + // Copy the file contents + foreach ( + var (sourceStream, destinationStream) in sourceStreams.Zip( + destinationStreams, + (s, d) => (s, d) + ) + ) + { + sourceStream.CopyTo(destinationStream); + + // Truncate the destination file if the source file is shorter + destinationStream.SetLength(sourceStream.Length); + + // Preserve Unix file permissions on non-Windows platforms + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + destinationStream.Name, + File.GetUnixFileMode(sourceStream.Name) + ); + } + } + } + finally + { + Disposable.Merge([.. sourceStreams, .. destinationStreams]).Dispose(); + } + } + /// /// Checks if it's possible to write to the specified directory. ///