// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dotnet;
using Aspire.Hosting.Utils;
#pragma warning disable ASPIREEXTENSION001 // WithDebugSupport is experimental
#pragma warning disable ASPIREPROJECTS001 // WithProjectDefaults is experimental
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding C# projects and file-based C# apps (by path) to an
/// <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static class DotnetProjectHostingExtensions
{
/// <summary>
/// Adds a C# project or file-based app to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource. This name will be used for service discovery when referenced in a dependency.</param>
/// <param name="path">The path to the file-based app file, project file, or project directory.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This overload of the <see cref="AddDotnetProject(IDistributedApplicationBuilder, string, string)"/> method adds a C# project or file-based app to the application
/// model using a path to the file-based app .cs file, project file (.csproj), or project directory.
/// If the path is not an absolute path then it will be computed relative to the app host directory.
/// </para>
/// <example>
/// Add a file-based app to the app model via a file path.
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddDotnetProject("inventoryservice", @"..\InventoryService.cs");
///
/// builder.Build().Run();
/// </code>
/// </example>
/// </remarks>
[Experimental("ASPIREDOTNETPROJECT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Polyglot AppHosts use the internal addDotnetProject dispatcher export.")]
public static IResourceBuilder<DotnetProjectResource> AddDotnetProject(this IDistributedApplicationBuilder builder, [ResourceName] string name, string path)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(path);
return builder.AddDotnetProject(name, path, _ => { });
}
/// <summary>
/// Adds a C# application resource.
/// </summary>
[Experimental("ASPIREDOTNETPROJECT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExport("addDotnetProject")]
internal static IResourceBuilder<DotnetProjectResource> AddDotnetProjectForPolyglot(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
string path,
ProjectResourceOptions? options = null)
{
return options is null
? builder.AddDotnetProject(name, path, _ => { })
: builder.AddDotnetProject(name, path, configure => ApplyProjectResourceOptions(configure, options));
}
/// <summary>
/// Adds a C# project or file-based app to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param>
/// <param name="name">The name of the resource. This name will be used for service discovery when referenced in a dependency.</param>
/// <param name="path">The path to the file-based app file, project file, or project directory.</param>
/// <param name="configure">An optional action to configure the C# app resource options.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This overload of the <see cref="AddDotnetProject(IDistributedApplicationBuilder, string, string)"/> method adds a C# project or file-based app to the application
/// model using a path to the file-based app .cs file, project file (.csproj), or project directory.
/// If the path is not an absolute path then it will be computed relative to the app host directory.
/// </para>
/// <example>
/// Add a file-based app to the app model via a file path.
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddDotnetProject("inventoryservice", @"..\InventoryService.cs", o => o.LaunchProfileName = "https");
///
/// builder.Build().Run();
/// </code>
/// </example>
/// </remarks>
[Experimental("ASPIREDOTNETPROJECT001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
[AspireExportIgnore(Reason = "Polyglot AppHosts use the internal addDotnetProject dispatcher export.")]
public static IResourceBuilder<DotnetProjectResource> AddDotnetProject(this IDistributedApplicationBuilder builder, [ResourceName] string name, string path, Action<ProjectResourceOptions> configure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(path);
ArgumentNullException.ThrowIfNull(configure);
var options = new ProjectResourceOptions();
configure(options);
path = PathNormalizer.NormalizePathForCurrentPlatform(Path.Combine(builder.AppHostDirectory, path));
var projectMetadata = new DotnetProjectMetadata(path);
// ExecutableResource requires a working directory. Use the project/app directory so the process
// launches from the same place a ProjectResource would (DCP used Path.GetDirectoryName(ProjectPath)).
// Accessing ProjectPath also resolves a project directory to its single .csproj. Falling back to the
// app host directory keeps construction valid for invalid paths, which are reported by the
// OnBeforeResourceStarted validation below.
var workingDirectory = Path.GetDirectoryName(projectMetadata.ProjectPath) ?? builder.AppHostDirectory;
var app = new DotnetProjectResource(name, workingDirectory);
// The app host's own build configuration (Debug/Release) is propagated to the child `dotnet run`
// so the service matches the app host, mirroring DistributedApplicationOptions.Configuration.
var configuration = builder.AppHostAssembly?.GetCustomAttribute<AssemblyConfigurationAttribute>()?.Configuration;
var resource = builder.AddResource(app)
.WithAnnotation(projectMetadata)
.WithIconName("CodeCsRectangle")
.WithProjectDefaults(options);
// Declare the default `dotnet run` invocation separately from the program arguments so a later
// WithLaunchToolArgs call replaces it instead of being prepended to it:
// dotnet run --project <proj> [--no-build] [--configuration <cfg>] --no-launch-profile OR
// dotnet run --file <app.cs> --no-cache [--no-build] [--configuration <cfg>] --no-launch-profile
resource.WithLaunchToolArgs(ctx =>
{
if (ctx.Resource.SupportsDebugging(builder.Configuration, out var debugAnnotation)
&& debugAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project)
{
return;
}
IProjectMetadata metadata = projectMetadata;
ctx.Args.Add("run");
ctx.Args.Add(metadata.IsFileBasedApp ? "--file" : "--project");
ctx.Args.Add(metadata.ProjectPath);
if (metadata.IsFileBasedApp)
{
ctx.Args.Add("--no-cache");
}
if (metadata.SuppressBuild)
{
ctx.Args.Add("--no-build");
}
if (!string.IsNullOrEmpty(configuration))
{
ctx.Args.Add("--configuration");
ctx.Args.Add(configuration);
}
// Always suppress the normal launch profile handling: the profile's settings would otherwise
// override the ambient environment, but those ambient settings come from the application model
// and must take priority. WithProjectDefaults materializes the profile's environment manually.
ctx.Args.Add("--no-launch-profile");
if (GetLaunchProfileArguments(ctx.Resource).Count > 0)
{
ctx.Args.Add("--");
}
}, ownedByLaunchConfigurationType: KnownLaunchConfigurationTypes.Project);
// Launch-profile command-line arguments belong to the program, not the replaceable tool invocation.
// Keeping them in the ordinary segment preserves them when a caller supplies a custom launch tool.
resource.WithArgs(ctx =>
{
foreach (var arg in GetLaunchProfileArguments(ctx.Resource))
{
ctx.Args.Add(arg);
}
});
List<string> GetLaunchProfileArguments(IResource resource)
{
// Project launch configurations carry the selected launch profile, so the IDE applies its command-line arguments.
if (!builder.ExecutionContext.IsRunMode
|| options.ExcludeLaunchProfile
|| (resource.SupportsDebugging(builder.Configuration, out var debugAnnotation)
&& debugAnnotation.LaunchConfigurationType is KnownLaunchConfigurationTypes.Project))
{
return [];
}
var launchProfile = resource.GetEffectiveLaunchProfile()?.LaunchProfile;
return launchProfile is not null && !string.IsNullOrWhiteSpace(launchProfile.CommandLineArgs)
? CommandLineArgsParser.Parse(launchProfile.CommandLineArgs)
: [];
}
resource.OnBeforeResourceStarted((r, e, ct) =>
{
var projectPath = projectMetadata.ProjectPath;
// Validate project path
if (!projectPath.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) && !projectPath.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
{
// Project path did not resolve to a .csproj or .cs file
var message = Directory.Exists(projectPath)
? $"Path to C# project could not be determined. The directory '{projectPath}' must contain a single .csproj file."
: $"The C# app path '{projectPath}' is invalid. The path must be to a .cs file, .csproj file, or directory containing a single .csproj file.";
throw new DistributedApplicationException(message);
}
// The minimum-SDK check for file-based apps is applied by WithProjectDefaults.
return Task.CompletedTask;
});
return resource;
}
private static void ApplyProjectResourceOptions(ProjectResourceOptions target, ProjectResourceOptions source)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(source);
target.LaunchProfileName = source.LaunchProfileName;
target.ExcludeLaunchProfile = source.ExcludeLaunchProfile;
target.ExcludeKestrelEndpoints = source.ExcludeKestrelEndpoints;
}
}