Skip to content

Commit 9db74fa

Browse files
committed
feat(sdk): add IBuildInfo + HostKind for build identity (#23)
New SDK service `Spectara.Revela.Sdk.Hosting.IBuildInfo` exposes the immutable build-time identity of the running host (Standalone vs. Embedded) plus version, framework, configuration, and runtime identifier. Single source of truth for `--version`, the upcoming `revela info` command, and any plugin that needs to branch on host kind (e.g. self-update plugins hiding themselves in embedded builds). Detection mechanism: `Revela.HostKind` assembly metadata attribute set in `Cli.Embedded.csproj`. Both Cli and Cli.Embedded produce an assembly named `revela`, so name-based detection is impossible — the metadata attribute sidesteps that collision and makes adding a third host variant (e.g. Cli.Embedded.Ai) a 3-line change. System.CommandLine's default `--version` action is replaced with a human-readable, host-kind-aware renderer: revela 1.0.0 (.NET 10.0.7) revela 1.0.0 (.NET 10.0.7) — embedded build Same string is reused as the first line of `revela info`, so both surfaces report the same identifier — no drift possible. Conceptual note: `IBuildInfo` is complementary to .NET's `IHostEnvironment`. The latter describes the runtime *deployment* environment (Dev/Staging/Prod, overridable via DOTNET_ENVIRONMENT); `IBuildInfo` describes the immutable *build-time* identity. The two answer different questions and don't overlap. Tests cover detection, version-string stripping, and FormatVersionLine for both HostKind values.
1 parent e18845c commit 9db74fa

8 files changed

Lines changed: 395 additions & 0 deletions

File tree

Spectara.Revela.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
<Folder Name="/tests/">
3434
<Project Path="tests/Core/Core.csproj" />
3535
<Project Path="tests/Commands/Commands.csproj" />
36+
<Project Path="tests/Cli/Cli.csproj" />
3637
<Project Path="tests/Integration/Integration.csproj" />
3738
<Project Path="tests/Shared/Shared.csproj" />
3839
</Folder>

src/Cli.Embedded/Cli.Embedded.csproj

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020
<Compile Include="..\Cli\Hosting\**\*.cs" LinkBase="Hosting" />
2121
</ItemGroup>
2222

23+
<!-- Marker read at runtime by BuildInfo to expose HostKind = Embedded
24+
(both Cli and Cli.Embedded produce 'revela' as AssemblyName, so
25+
assembly-name-based detection is impossible). -->
26+
<ItemGroup>
27+
<AssemblyMetadata Include="Revela.HostKind" Value="Embedded" />
28+
</ItemGroup>
29+
2330
<!-- Core + Commands (same as Cli) -->
2431
<ItemGroup>
2532
<ProjectReference Include="..\Core\Core.csproj" />

src/Cli/Hosting/BuildInfo.cs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
using System.Globalization;
2+
using System.Reflection;
3+
using System.Runtime.InteropServices;
4+
5+
using Spectara.Revela.Sdk.Hosting;
6+
7+
namespace Spectara.Revela.Cli.Hosting;
8+
9+
/// <summary>
10+
/// Default <see cref="IBuildInfo"/> implementation.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para>
14+
/// Detects <see cref="HostKind"/> by reading the <c>Revela.HostKind</c>
15+
/// <see cref="AssemblyMetadataAttribute"/> from the entry assembly. Default
16+
/// when the attribute is absent is <see cref="HostKind.Standalone"/>.
17+
/// </para>
18+
/// <para>
19+
/// Both Cli and Cli.Embedded produce an executable named <c>revela</c>, so
20+
/// assembly-name-based detection is impossible. The metadata attribute is the
21+
/// single source of truth, set in <c>Cli.Embedded.csproj</c> via:
22+
/// </para>
23+
/// <code>
24+
/// &lt;ItemGroup&gt;
25+
/// &lt;AssemblyMetadata Include="Revela.HostKind" Value="Embedded" /&gt;
26+
/// &lt;/ItemGroup&gt;
27+
/// </code>
28+
/// </remarks>
29+
internal sealed class BuildInfo : IBuildInfo
30+
{
31+
private const string HostKindMetadataKey = "Revela.HostKind";
32+
33+
private const string BuildConfiguration =
34+
#if DEBUG
35+
"Debug";
36+
#else
37+
"Release";
38+
#endif
39+
40+
public BuildInfo()
41+
: this(Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly())
42+
{
43+
}
44+
45+
/// <summary>
46+
/// Test seam: construct from a specific assembly, bypassing entry-assembly
47+
/// auto-detection. Used by BuildInfoTests.
48+
/// </summary>
49+
internal BuildInfo(Assembly entry)
50+
{
51+
Kind = DetectHostKind(entry);
52+
InformationalVersion = DetectInformationalVersion(entry);
53+
Version = StripBuildMetadata(InformationalVersion);
54+
Framework = RuntimeInformation.FrameworkDescription;
55+
Configuration = BuildConfiguration;
56+
RuntimeIdentifier = RuntimeInformation.RuntimeIdentifier;
57+
}
58+
59+
/// <summary>
60+
/// Test seam: construct directly from raw values, fully bypassing assembly
61+
/// inspection. Used by BuildInfoTests for FormatVersionLine assertions
62+
/// across both <see cref="HostKind"/> values.
63+
/// </summary>
64+
internal BuildInfo(
65+
HostKind kind,
66+
string version,
67+
string informationalVersion,
68+
string framework,
69+
string configuration,
70+
string runtimeIdentifier)
71+
{
72+
Kind = kind;
73+
Version = version;
74+
InformationalVersion = informationalVersion;
75+
Framework = framework;
76+
Configuration = configuration;
77+
RuntimeIdentifier = runtimeIdentifier;
78+
}
79+
80+
public HostKind Kind { get; }
81+
82+
public string Version { get; }
83+
84+
public string InformationalVersion { get; }
85+
86+
public string Framework { get; }
87+
88+
public string Configuration { get; }
89+
90+
public string RuntimeIdentifier { get; }
91+
92+
public string FormatVersionLine()
93+
{
94+
var suffix = Kind switch
95+
{
96+
HostKind.Embedded => " \u2014 embedded build",
97+
HostKind.Standalone => string.Empty,
98+
_ => string.Empty,
99+
};
100+
return string.Create(
101+
CultureInfo.InvariantCulture,
102+
$"revela {Version} ({Framework}){suffix}");
103+
}
104+
105+
private static HostKind DetectHostKind(Assembly entry)
106+
{
107+
var value = entry
108+
.GetCustomAttributes<AssemblyMetadataAttribute>()
109+
.FirstOrDefault(a => string.Equals(a.Key, HostKindMetadataKey, StringComparison.Ordinal))
110+
?.Value;
111+
112+
return string.Equals(value, nameof(HostKind.Embedded), StringComparison.Ordinal)
113+
? HostKind.Embedded
114+
: HostKind.Standalone;
115+
}
116+
117+
/// <summary>Test seam — exposes <see cref="DetectHostKind"/>.</summary>
118+
internal static HostKind DetectHostKindForTesting(Assembly entry) => DetectHostKind(entry);
119+
120+
private static string DetectInformationalVersion(Assembly entry)
121+
{
122+
var info = entry.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
123+
if (!string.IsNullOrWhiteSpace(info))
124+
{
125+
return info;
126+
}
127+
128+
var version = entry.GetName().Version;
129+
return version is null
130+
? "0.0.0"
131+
: version.ToString(3);
132+
}
133+
134+
private static string StripBuildMetadata(string informational)
135+
{
136+
var plus = informational.IndexOf('+', StringComparison.Ordinal);
137+
return plus < 0 ? informational : informational[..plus];
138+
}
139+
140+
/// <summary>Test seam — exposes <see cref="StripBuildMetadata"/>.</summary>
141+
internal static string StripBuildMetadataForTesting(string informational) => StripBuildMetadata(informational);
142+
}

src/Cli/Hosting/HostBootstrap.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
using System.CommandLine;
2+
using System.CommandLine.Invocation;
3+
using System.CommandLine.Parsing;
14
using System.Text;
25
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.DependencyInjection.Extensions;
37
using Microsoft.Extensions.Hosting;
48
using Spectara.Revela.Commands;
59
using Spectara.Revela.Core.Configuration;
610
using Spectara.Revela.Sdk;
711
using Spectara.Revela.Sdk.Abstractions;
812
using Spectara.Revela.Sdk.Configuration;
13+
using Spectara.Revela.Sdk.Hosting;
914

1015
namespace Spectara.Revela.Cli.Hosting;
1116

@@ -50,6 +55,11 @@ public static HostApplicationBuilder ConfigureRevela(
5055
builder.Services.AddInteractiveMode();
5156
builder.Services.AddPackages(packageSource, builder.Configuration, args);
5257

58+
// Build identity (HostKind, Version, Framework, ...) — single source
59+
// of truth for `--version` and `revela info`. Idempotent registration
60+
// so tests can override.
61+
builder.Services.TryAddSingleton<IBuildInfo, BuildInfo>();
62+
5363
// Register ProjectEnvironment (runtime info about project location)
5464
builder.Services.AddOptions<ProjectEnvironment>()
5565
.Configure<IHostEnvironment>((env, host) => env.Path = host.ContentRootPath);
@@ -67,6 +77,13 @@ public static async Task<int> RunRevelaAsync(this IHost host, string[] args)
6777
{
6878
var rootCommand = host.UseRevelaCommands();
6979

80+
// Replace System.CommandLine's default --version action with one that
81+
// prints the human-readable, host-kind-aware identifier. Same string
82+
// is used as the first line of `revela info`.
83+
var buildInfo = host.Services.GetRequiredService<IBuildInfo>();
84+
var versionOption = rootCommand.Options.OfType<VersionOption>().FirstOrDefault();
85+
versionOption?.Action = new BuildInfoVersionAction(buildInfo);
86+
7087
// Detect interactive mode: no arguments AND interactive terminal
7188
var isInteractiveMode = args.Length == 0
7289
&& !Console.IsInputRedirected
@@ -82,4 +99,19 @@ public static async Task<int> RunRevelaAsync(this IHost host, string[] args)
8299

83100
return await rootCommand.Parse(args).InvokeAsync();
84101
}
102+
103+
/// <summary>
104+
/// Synchronous <c>--version</c> action that prints
105+
/// <see cref="IBuildInfo.FormatVersionLine"/>.
106+
/// </summary>
107+
private sealed class BuildInfoVersionAction(IBuildInfo buildInfo) : SynchronousCommandLineAction
108+
{
109+
public override bool ClearsParseErrors => true;
110+
111+
public override int Invoke(ParseResult parseResult)
112+
{
113+
parseResult.InvocationConfiguration.Output.WriteLine(buildInfo.FormatVersionLine());
114+
return 0;
115+
}
116+
}
85117
}

src/Sdk/Hosting/IBuildInfo.cs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
namespace Spectara.Revela.Sdk.Hosting;
2+
3+
/// <summary>
4+
/// Immutable build-time identity of the running Revela host.
5+
/// </summary>
6+
/// <remarks>
7+
/// <para>
8+
/// Single source of truth for version data exposed via <c>revela --version</c>,
9+
/// <c>revela info</c>, and any plugin that needs to branch on host kind.
10+
/// </para>
11+
/// <para>
12+
/// <b>Note:</b> <see cref="IBuildInfo"/> describes the <em>build-time</em>
13+
/// identity of the host (Standalone vs. Embedded), determined at compile time
14+
/// via the <c>Revela.HostKind</c> assembly metadata attribute.
15+
/// It is not user-overridable.
16+
/// </para>
17+
/// <para>
18+
/// This is conceptually distinct from
19+
/// <c>Microsoft.Extensions.Hosting.IHostEnvironment</c>, which describes the
20+
/// runtime <em>deployment</em> environment (Development/Staging/Production)
21+
/// and is overridable via <c>DOTNET_ENVIRONMENT</c>. The two are
22+
/// complementary: <c>IHostEnvironment</c> answers "where am I running?",
23+
/// <c>IBuildInfo</c> answers "what build am I?".
24+
/// </para>
25+
/// </remarks>
26+
public interface IBuildInfo
27+
{
28+
/// <summary>Build variant of the running host.</summary>
29+
HostKind Kind { get; }
30+
31+
/// <summary>Clean semantic version, e.g. <c>"1.0.0"</c>.</summary>
32+
string Version { get; }
33+
34+
/// <summary>
35+
/// Full informational version including build metadata,
36+
/// e.g. <c>"1.0.0+abc1234"</c>. Use this in bug reports.
37+
/// </summary>
38+
string InformationalVersion { get; }
39+
40+
/// <summary>.NET runtime description, e.g. <c>".NET 10.0.4"</c>.</summary>
41+
string Framework { get; }
42+
43+
/// <summary>Build configuration: <c>"Debug"</c> or <c>"Release"</c>.</summary>
44+
string Configuration { get; }
45+
46+
/// <summary>Runtime identifier, e.g. <c>"linux-x64"</c>.</summary>
47+
string RuntimeIdentifier { get; }
48+
49+
/// <summary>
50+
/// Single-line human-readable summary used by both <c>--version</c>
51+
/// and the first line of <c>revela info</c>.
52+
/// </summary>
53+
/// <example>
54+
/// <c>revela 1.0.0 (.NET 10.0.4) — embedded build</c>
55+
/// </example>
56+
string FormatVersionLine();
57+
}
58+
59+
/// <summary>
60+
/// Build variant of the running Revela host.
61+
/// </summary>
62+
public enum HostKind
63+
{
64+
/// <summary>
65+
/// Standard standalone CLI build with dynamic plugin loading
66+
/// (the <c>revela</c> dotnet tool).
67+
/// </summary>
68+
Standalone,
69+
70+
/// <summary>
71+
/// Self-contained build with all plugins and themes statically linked.
72+
/// No plugin management commands are available.
73+
/// </summary>
74+
Embedded,
75+
}

tests/Cli/AssemblyInfo.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Enable parallel test execution at assembly level
2+
[assembly: Parallelize(Workers = 0, Scope = ExecutionScope.MethodLevel)]

tests/Cli/Cli.csproj

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<OutputType>Exe</OutputType>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<IsPackable>false</IsPackable>
9+
10+
<!-- MSTest Runner Configuration -->
11+
<EnableMSTestRunner>true</EnableMSTestRunner>
12+
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>
13+
14+
<!-- Disable CA1515 - MSTest requires public test classes -->
15+
<NoWarn>$(NoWarn);CA1515</NoWarn>
16+
</PropertyGroup>
17+
18+
<ItemGroup>
19+
<ProjectReference Include="..\..\src\Cli\Cli.csproj" />
20+
<ProjectReference Include="..\Shared\Shared.csproj" />
21+
</ItemGroup>
22+
23+
<ItemGroup>
24+
<PackageReference Include="MSTest" />
25+
<PackageReference Include="MSTest.Analyzers" />
26+
<PackageReference Include="NSubstitute" />
27+
</ItemGroup>
28+
29+
<ItemGroup>
30+
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
31+
</ItemGroup>
32+
33+
</Project>

0 commit comments

Comments
 (0)