diff --git a/src/DemaConsulting.NuGetInstaller/DemaConsulting.NuGetInstaller.csproj b/src/DemaConsulting.NuGetInstaller/DemaConsulting.NuGetInstaller.csproj
index 3277ead..83e4a0a 100644
--- a/src/DemaConsulting.NuGetInstaller/DemaConsulting.NuGetInstaller.csproj
+++ b/src/DemaConsulting.NuGetInstaller/DemaConsulting.NuGetInstaller.csproj
@@ -48,7 +48,7 @@
-
+
diff --git a/src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs b/src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs
index 02aa0b4..70f24de 100644
--- a/src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs
+++ b/src/DemaConsulting.NuGetInstaller/NuGet/PackageInstaller.cs
@@ -36,6 +36,12 @@ internal static class PackageInstaller
/// The list of packages to install.
/// The output directory for package extraction.
/// When , use {Id}/ folder naming instead of {Id}.{Version}/.
+ ///
+ /// The directory from which to discover the applicable nuget.config (typically the
+ /// directory containing the packages.config file). When ,
+ ///
+ /// falls back to the current working directory.
+ ///
/// A task representing the asynchronous operation.
/// Thrown when is .
/// Thrown when is empty.
@@ -43,7 +49,8 @@ public static async Task InstallAsync(
Context context,
IReadOnlyList packages,
string outputDirectory,
- bool excludeVersion)
+ bool excludeVersion,
+ string? configRoot = null)
{
ArgumentNullException.ThrowIfNull(outputDirectory);
ArgumentException.ThrowIfNullOrEmpty(outputDirectory);
@@ -53,7 +60,7 @@ public static async Task InstallAsync(
// Install all packages in parallel
await Task.WhenAll(packages.Select(entry =>
- InstallPackageAsync(context, entry, outputDirectory, excludeVersion))).ConfigureAwait(false);
+ InstallPackageAsync(context, entry, outputDirectory, excludeVersion, configRoot))).ConfigureAwait(false);
}
///
@@ -63,15 +70,22 @@ await Task.WhenAll(packages.Select(entry =>
/// The package entry to install.
/// The output directory for package extraction.
/// When , use {Id}/ folder naming instead of {Id}.{Version}/.
+ ///
+ /// The directory from which to discover the applicable nuget.config, forwarded to
+ /// .
+ ///
/// A task representing the asynchronous operation.
private static async Task InstallPackageAsync(
Context context,
PackageEntry entry,
string outputDirectory,
- bool excludeVersion)
+ bool excludeVersion,
+ string? configRoot)
{
- // Ensure the package is in the local NuGet cache
- var cacheFolder = await NuGetCache.EnsureCachedAsync(entry.Id, entry.Version).ConfigureAwait(false);
+ // Ensure the package is in the local NuGet cache. Passing configRoot (typically the
+ // directory containing packages.config) allows a project/repo-local nuget.config to be
+ // discovered the same way `dotnet restore` discovers it.
+ var cacheFolder = await NuGetCache.EnsureCachedAsync(entry.Id, entry.Version, configRoot).ConfigureAwait(false);
// Build paths - NuGet global package cache uses lowercase for filenames
var nupkgPath = Path.Combine(cacheFolder, $"{entry.Id}.{entry.Version}.nupkg".ToLowerInvariant());
diff --git a/src/DemaConsulting.NuGetInstaller/Program.cs b/src/DemaConsulting.NuGetInstaller/Program.cs
index d8cae59..5e5f9ce 100644
--- a/src/DemaConsulting.NuGetInstaller/Program.cs
+++ b/src/DemaConsulting.NuGetInstaller/Program.cs
@@ -241,8 +241,12 @@ private static void RunToolLogic(Context context)
// Resolve output directory
var outputDirectory = context.OutputDirectory ?? Directory.GetCurrentDirectory();
+ // Resolve the directory containing packages.config so a project/repo-local nuget.config
+ // is discovered the same way `dotnet restore` discovers it
+ var configRoot = Path.GetDirectoryName(Path.GetFullPath(context.PackagesConfigFile));
+
// Install
- PackageInstaller.InstallAsync(context, packages, outputDirectory, context.ExcludeVersion)
+ PackageInstaller.InstallAsync(context, packages, outputDirectory, context.ExcludeVersion, configRoot)
.GetAwaiter().GetResult();
}
}
diff --git a/test/DemaConsulting.NuGetInstaller.Tests/ProgramConfigRootTests.cs b/test/DemaConsulting.NuGetInstaller.Tests/ProgramConfigRootTests.cs
new file mode 100644
index 0000000..f906ac0
--- /dev/null
+++ b/test/DemaConsulting.NuGetInstaller.Tests/ProgramConfigRootTests.cs
@@ -0,0 +1,238 @@
+// Copyright (c) DEMA Consulting
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+using System.IO.Compression;
+using DemaConsulting.NuGetInstaller.Cli;
+
+namespace DemaConsulting.NuGetInstaller.Tests;
+
+///
+/// End-to-end tests proving that derives a configRoot from
+/// the packages.config location and forwards it through to NuGet settings discovery,
+/// so a project/repo-local nuget.config is found the same way dotnet restore
+/// finds it - regardless of the process's actual current working directory.
+///
+///
+/// This is a regression test for GitHub issue #37: NuGetCache.EnsureCachedAsync
+/// previously ignored any repo-local nuget.config because no root directory was
+/// forwarded to Settings.LoadDefaultSettings. Each test here builds an isolated
+/// temporary "repository" containing a nuget.config at its root (defining a local
+/// folder package source and a private global packages folder, so no real machine state
+/// is touched) and a nested packages.config in a subdirectory - mirroring the
+/// HiArc.LaunchCode.HardwareSim layout where nuget.config lives several
+/// directories above the project's packages.config.
+///
+public class ProgramConfigRootTests
+{
+ ///
+ /// Test that Program.Run installs a package from a source defined only in a
+ /// repo-local nuget.config located in an ancestor of the packages.config
+ /// directory, proving the derived configRoot is honored end-to-end.
+ ///
+ [Fact]
+ public void Program_Run_WithRepoLocalNugetConfigAboveNestedPackagesConfig_InstallsFromLocalSource()
+ {
+ // Arrange - build an isolated "repository" layout:
+ // repoRoot/
+ // nuget.config <- defines the only source that can resolve the package
+ // feed/{id}.{version}.nupkg <- the package content
+ // project/packages.config <- references the package; several levels of walk-up
+ // from here reach nuget.config, matching the real
+ // HardwareSim repo layout
+ // packages/ <- installer output directory
+ // global-packages/ <- private global packages folder (keeps this test
+ // from touching the real machine-wide NuGet cache)
+ var repoRoot = Path.Combine(Path.GetTempPath(), $"nuget_installer_config_root_test_{Guid.NewGuid():N}");
+ var feedDir = Path.Combine(repoRoot, "feed");
+ var projectDir = Path.Combine(repoRoot, "project");
+ var outputDir = Path.Combine(repoRoot, "packages");
+ var globalPackagesDir = Path.Combine(repoRoot, "global-packages");
+ var packageId = $"Test.ConfigRootDiscovery.{Guid.NewGuid():N}";
+ const string version = "1.0.0";
+
+ try
+ {
+ Directory.CreateDirectory(feedDir);
+ Directory.CreateDirectory(projectDir);
+ Directory.CreateDirectory(globalPackagesDir);
+
+ // Write the package into the local folder feed
+ File.WriteAllBytes(
+ Path.Combine(feedDir, $"{packageId}.{version}.nupkg"),
+ CreateMinimalNupkgBytes(packageId, version));
+
+ // Write a repo-local nuget.config at the repo root defining the local feed as the
+ // only source and a private global packages folder
+ File.WriteAllText(Path.Combine(repoRoot, "nuget.config"), $"""
+
+
+
+
+
+
+
+
+
+
+ """);
+
+ // Write packages.config nested below the repo root, matching how HardwareSim's
+ // packages.config sits several directories below its repo-root nuget.config
+ var configPath = Path.Combine(projectDir, "packages.config");
+ File.WriteAllText(configPath, $"""
+
+
+
+
+ """);
+
+ var originalOut = Console.Out;
+ try
+ {
+ using var outWriter = new StringWriter();
+ Console.SetOut(outWriter);
+ using var context = Context.Create([configPath, "-o", outputDir]);
+
+ // Act - run the program; the process's actual working directory is unrelated to
+ // repoRoot, so success can only be explained by Program deriving configRoot from
+ // the packages.config location and NuGetCache honoring it
+ Program.Run(context);
+
+ // Assert - installation succeeded and the package was extracted
+ Assert.Equal(0, context.ExitCode);
+ var expectedFolder = Path.Combine(outputDir, $"{packageId}.{version}");
+ Assert.True(
+ Directory.Exists(expectedFolder),
+ $"Expected package folder to exist at: {expectedFolder}");
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ }
+ }
+ finally
+ {
+ if (Directory.Exists(repoRoot))
+ {
+ Directory.Delete(repoRoot, recursive: true);
+ }
+ }
+ }
+
+ ///
+ /// Test that Program.Run fails to resolve a package whose only source is defined
+ /// in a nuget.config that does not exist anywhere above the packages.config
+ /// directory, proving the previous test's success is due to genuine discovery of the
+ /// repo-local config rather than an unrelated fallback.
+ ///
+ [Fact]
+ public void Program_Run_WithoutRepoLocalNugetConfig_PackageNotFound_ThrowsInvalidOperationException()
+ {
+ // Arrange - same nested packages.config as above, but with no nuget.config anywhere in
+ // the temp tree, so the package (which does not exist on any real source) cannot resolve
+ var repoRoot = Path.Combine(Path.GetTempPath(), $"nuget_installer_config_root_test_{Guid.NewGuid():N}");
+ var projectDir = Path.Combine(repoRoot, "project");
+ var outputDir = Path.Combine(repoRoot, "packages");
+ var packageId = $"Test.ConfigRootDiscovery.{Guid.NewGuid():N}";
+ const string version = "1.0.0";
+
+ try
+ {
+ Directory.CreateDirectory(projectDir);
+
+ var configPath = Path.Combine(projectDir, "packages.config");
+ File.WriteAllText(configPath, $"""
+
+
+
+
+ """);
+
+ var originalOut = Console.Out;
+ try
+ {
+ using var outWriter = new StringWriter();
+ Console.SetOut(outWriter);
+ using var context = Context.Create([configPath, "-o", outputDir]);
+
+ // Act & Assert - with no repo-local source defining this package, resolution must
+ // fail; NuGetCache.EnsureCachedAsync surfaces this as InvalidOperationException,
+ // which Program.Run (called directly, bypassing Main's try/catch) propagates
+ Assert.Throws(() => Program.Run(context));
+ }
+ finally
+ {
+ Console.SetOut(originalOut);
+ }
+ }
+ finally
+ {
+ if (Directory.Exists(repoRoot))
+ {
+ Directory.Delete(repoRoot, recursive: true);
+ }
+ }
+ }
+
+ ///
+ /// Builds a minimal valid .nupkg byte array for the given package identity using only
+ /// (no NuGet SDK package-building types are referenced by this
+ /// test project). NuGet's local-folder feed resolution only requires a top-level
+ /// *.nuspec entry containing valid package metadata; full OPC parts
+ /// ([Content_Types].xml, relationship parts) are not required for reading.
+ ///
+ /// The NuGet package identifier.
+ /// The package version string.
+ /// A byte array containing a minimal .nupkg archive.
+ private static byte[] CreateMinimalNupkgBytes(string packageId, string version)
+ {
+ using var stream = new MemoryStream();
+ using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ // Top-level nuspec entry - required by NuGet's package reader to identify the package
+ var nuspecEntry = archive.CreateEntry($"{packageId}.nuspec");
+ using (var entryStream = nuspecEntry.Open())
+ using (var writer = new StreamWriter(entryStream))
+ {
+ writer.Write($"""
+
+
+
+ {packageId}
+ {version}
+ Test
+ Minimal test package for configRoot discovery tests.
+
+
+ """);
+ }
+
+ // A placeholder content file so the extracted package folder is non-empty
+ var contentEntry = archive.CreateEntry("lib/net8.0/_placeholder.txt");
+ using (var entryStream = contentEntry.Open())
+ using (var writer = new StreamWriter(entryStream))
+ {
+ writer.Write("placeholder");
+ }
+ }
+
+ return stream.ToArray();
+ }
+}