Skip to content
8 changes: 4 additions & 4 deletions src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.1" />
<PackageVersion Include="Xcaciv.Command.Interface" Version="3.3.2" />
<PackageVersion Include="Xcaciv.Command.Core" Version="3.3.2" />
<PackageVersion Include="Xcaciv.Command.FileLoader" Version="3.3.2" />
<PackageVersion Include="Xcaciv.Command" Version="3.3.2" />
<PackageVersion Include="Xcaciv.Command.Interface" Version="3.3.3" />
<PackageVersion Include="Xcaciv.Command.Core" Version="3.3.3" />
<PackageVersion Include="Xcaciv.Command.FileLoader" Version="3.3.3" />
<PackageVersion Include="Xcaciv.Command" Version="3.3.3" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<!-- Unit Test Versions -->
Expand Down
6 changes: 3 additions & 3 deletions src/Xcaciv.Command.Core/Xcaciv.Command.Core.csproj
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.4</Version>
<AssemblyName>Xcaciv.Command.Core</AssemblyName>
<RootNamespace>Xcaciv.Command.Core</RootNamespace>
<IsPublishable>True</IsPublishable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<Version>3.3.4</Version>
<TargetFrameworks>$(XcacivTargetFrameworks)</TargetFrameworks>
<AssemblyName>Xcaciv.Command.DependencyInjection</AssemblyName>
<RootNamespace>Xcaciv.Command.DependencyInjection</RootNamespace>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<TargetFrameworks>$(XcacivTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.1</Version>
<Version>3.3.4</Version>
<IsPublishable>True</IsPublishable>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>
<AssemblyName>Xcaciv.Command.Extensions.Commandline</AssemblyName>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<TargetFrameworks>$(XcacivTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<Version>3.3.4</Version>
<AssemblyName>Xcaciv.Command.FileLoader</AssemblyName>
<RootNamespace>Xcaciv.Command.FileLoader</RootNamespace>
<PackageLicenseExpression>AGPL-3.0-only</PackageLicenseExpression>
Expand Down
8 changes: 8 additions & 0 deletions src/Xcaciv.Command.Interface/ICommandController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,13 @@ public interface ICommandController
/// <param name="modifiesEnvironment">Indicates whether the command modifies the environment. Set to <see langword="true"/> if the command alters
/// environment variables or state; otherwise, <see langword="false"/>.</param>
void AddCommand(string packageKey, ICommandDelegate command, bool modifiesEnvironment = false);
/// <summary>
/// Gets the current environment context for the controller.
/// </summary>
/// <returns>An instance of <see cref="IControllerEnvironmentContext"/> representing the default environment context given the commands registered.</returns>
/// <remarks> Practically, this method crawls the command stack to build a composite environment context calling GetEnvironment() on each command
/// in the registry.The resulting context reflects the cumulative environment state as influenced by all commands in the registry.
/// </remarks>
IControllerEnvironmentContext GetEnvironment();
}
}
8 changes: 8 additions & 0 deletions src/Xcaciv.Command.Interface/ICommandRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,12 @@ public interface ICommandRegistry
/// Returns all registered commands (root commands only).
/// </summary>
IEnumerable<ICommandDescription> GetAllCommands();

/// <summary>
/// Builds a composite environment context by crawling all registered commands
/// and collecting their default environment values via <see cref="ICommandDelegate.GetDefaultEnvironment"/>.
/// </summary>
/// <param name="commandFactory">Factory used to instantiate commands for retrieving their default environments.</param>
/// <returns>An <see cref="IControllerEnvironmentContext"/> populated with command-scoped defaults.</returns>
IControllerEnvironmentContext GetEnvironment(ICommandFactory commandFactory);
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public interface IControllerEnvironmentContext : ICommandContext<IControllerEnvi
/// Returns a snapshot of the current environment. Modifications to the returned
/// dictionary do not affect the environment context; use SetValue() to modify variables.
/// </remarks>
Dictionary<string, string> GetEnvironment(string commandName);
Dictionary<string, string> GetEnvironment(string commandName, bool prefix = true);

/// <summary>
/// Indicates whether this context has modified any environment variables.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<LangVersion>14</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<Version>3.3.4</Version>
<IsPublishable>True</IsPublishable>
<AssemblyName>Xcaciv.Command.Interface</AssemblyName>
<RootNamespace>Xcaciv.Command.Interface</RootNamespace>
Expand Down
28 changes: 24 additions & 4 deletions src/Xcaciv.Command/CommandController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO.Abstractions;
using System.Threading.Tasks;
using Xcaciv.Command.Commands;
Expand Down Expand Up @@ -252,8 +253,8 @@ public async Task Run(string commandText, IIoContext ioContext, IControllerEnvir

if (controllerEnvironmentChild.HasChanged && _commandRegistry.TryGetCommand(lastCommandName, out var commandDesc) && commandDesc?.ModifiesEnvironment == true)
{
env.UpdateEnvironment(controllerEnvironmentChild.GetEnvironment());
env.UpdateEnvironment(controllerEnvironmentChild.GetEnvironment(lastCommandName), lastCommandName);
var modifiedEnv = controllerEnvironmentChild.GetEnvironment(lastCommandName, false);
env.UpdateEnvironment(modifiedEnv);
}
}
else
Expand All @@ -264,9 +265,19 @@ public async Task Run(string commandText, IIoContext ioContext, IControllerEnvir

var childEnv = await env.GetChild(commandName).ConfigureAwait(false);
await ExecuteCommandInternal(commandName, ioContext, childEnv, cancellationToken).ConfigureAwait(false);
if (childEnv.HasChanged && _commandRegistry.TryGetCommand(commandName, out var commandDesc) && commandDesc?.ModifiesEnvironment == true)
if (childEnv.HasChanged && _commandRegistry.TryGetCommand(commandName, out var commandDesc))
{
env.UpdateEnvironment(childEnv.GetEnvironment(), commandName);
// if this is a special command, it can update global values
if (commandDesc?.ModifiesEnvironment == true)
{
env.UpdateEnvironment(childEnv.GetEnvironment());
}
else
{
// only allow commands to update their own environment values
var envUpdate = childEnv.GetEnvironment().Where(x => x.Key.StartsWith(commandName + "_", StringComparison.OrdinalIgnoreCase)).ToDictionary(x => x.Key.Substring(commandName.Length + 1), x => x.Value);
env.UpdateEnvironment(envUpdate, commandName);
}
}
}
}
Expand Down Expand Up @@ -304,4 +315,13 @@ private Task HandleHelpRequest(string commandKey, IIoContext ioContext, IEnviron

return _commandExecutor.GetHelpAsync(targetCommand, ioContext, env, cancellationToken);
}

/// <summary>
/// Gets the current environment context for the controller.
/// </summary>
/// <returns>An instance of <see cref="IControllerEnvironmentContext"/> representing the default environment context given the commands registered.</returns>
public IControllerEnvironmentContext GetEnvironment()
{
return _commandRegistry.GetEnvironment(_commandFactory);
}
}
57 changes: 57 additions & 0 deletions src/Xcaciv.Command/CommandRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,61 @@ public IEnumerable<ICommandDescription> GetAllCommands()
{
return new List<ICommandDescription>(_commands.Values);
}

/// <inheritdoc />
public IControllerEnvironmentContext GetEnvironment(ICommandFactory commandFactory)
{
ArgumentNullException.ThrowIfNull(commandFactory);

var controllerEnvironment = new ControllerEnvironmentContext();
foreach (var commandDescription in GetAllCommands())
{
ApplyDefaultEnvironment(controllerEnvironment, commandDescription, commandFactory);
}

return controllerEnvironment;
}

private static void ApplyDefaultEnvironment(IControllerEnvironmentContext controllerEnvironment, ICommandDescription commandDescription, ICommandFactory commandFactory, int depth = 0)
{
if (!string.IsNullOrWhiteSpace(commandDescription.FullTypeName))
{
AddCommandDefaults(controllerEnvironment, commandDescription, commandFactory);
}

if (commandDescription.SubCommands.Count == 0)
{
return;
}

foreach (var subCommand in commandDescription.SubCommands.Values)
{
AddCommandDefaults(controllerEnvironment, subCommand, commandFactory);
}
}
Comment on lines 101 to 117
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApplyDefaultEnvironment increments a depth parameter and then immediately blocks traversal when depth > 1, but the method never recurses (it only iterates direct SubCommands). This makes the depth logic misleading/dead code; either remove it or implement true recursive traversal if deeper nesting is expected.

Copilot uses AI. Check for mistakes.

private static void AddCommandDefaults(IControllerEnvironmentContext controllerEnvironment, ICommandDescription commandDescription, ICommandFactory commandFactory)
{
var commandInstance = commandFactory.CreateCommand(commandDescription.FullTypeName, commandDescription.PackageDescription.FullPath);
try
{
var defaultEnvironment = commandInstance.GetDefaultEnvironment();
if (defaultEnvironment.Count == 0)
{
return;
}

var commandName = NamesValidator.GetValidCommandName(commandDescription.BaseCommand);
controllerEnvironment.UpdateEnvironment(defaultEnvironment, commandName);
}
finally
{
commandInstance
.DisposeAsync()
.AsTask()
.ConfigureAwait(false)
.GetAwaiter()
.GetResult();
}
}
}
102 changes: 58 additions & 44 deletions src/Xcaciv.Command/ControllerEnvironmentContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,17 +149,28 @@ public Dictionary<string, string> GetEnvironment()
/// environment variables are returned.</param>
/// <returns>A dictionary containing the environment variables for the specified command name. Returns an empty
/// dictionary if the command name does not exist in the command environment.</returns>
public Dictionary<string, string> GetEnvironment(string commandName)
public Dictionary<string, string> GetEnvironment(string commandName, bool prefix = true)
{
if (String.IsNullOrEmpty(commandName))
{
return this._environment.GetEnvironment();
}
else
{
return _commandEnvironment.TryGetValue(commandName, out var commandEnv)
? new Dictionary<string, string>(commandEnv)
: new Dictionary<string, string>();
if (_commandEnvironment.TryGetValue(commandName, out var commandEnv))
{
if (!prefix) return commandEnv.ToDictionary(StringComparer.OrdinalIgnoreCase);
// Add command prefix to keys when retrieving
var commandPrefix = string.Concat(commandName, "_");
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in commandEnv)
{
var prefixedKey = key.StartsWith(commandPrefix, StringComparison.OrdinalIgnoreCase) ? key : commandPrefix + key;
result[prefixedKey] = value;
}
return result;
}
return new Dictionary<string, string>();
}
}
/// <summary>
Expand Down Expand Up @@ -204,26 +215,45 @@ public void SetValue(string key, string addValue, string commandName = "")
/// <summary>
/// Updates the environment with the specified key-value pairs.
/// </summary>
/// <remarks>This method applies the updates to the environment immediately. Ensure that the
/// dictionary contains valid keys for the environment settings.</remarks>
/// <remarks>The updates are applied immediately to the environment. The dictionary parameter must
/// not be null and should contain valid keys recognized by the environment.</remarks>
/// <param name="dictionary">A dictionary containing the environment settings to update. Each key represents a configuration setting, and
/// its corresponding value specifies the new value to apply. Cannot be null.</param>
/// its corresponding value specifies the new value to apply.</param>
public void UpdateEnvironment(Dictionary<string, string> dictionary)
{
_environment.UpdateEnvironment(dictionary);
var clean = RemoveCommandPrefixedValues(dictionary);
_environment.UpdateEnvironment(clean);
}
/// <summary>
/// removes any keys from the provided dictionary that are prefixed with a command name, ensuring that only global environment variables are updated.
/// </summary>
/// <param name="dictionary"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
private Dictionary<string, string> RemoveCommandPrefixedValues(Dictionary<string, string> dictionary)
{
var commandPrefixes = _commandEnvironment.Keys.Select(commandName => string.Concat(commandName, "_")).ToList();
var clean = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in dictionary)
{
if (commandPrefixes.Any(prefix => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
Trace.WriteLine($"Skipping command-prefixed key {key} during global environment update.");
continue;
}
clean[key] = value;
}
return clean;
}

/// <summary>
/// Updates the environment with the specified key-value pairs, applying updates either globally or to a
/// command-specific environment based on the provided command name.
/// Updates environment variables with the specified key-value pairs, either globally or for a specific command.
/// </summary>
/// <remarks>If a command name is specified, only dictionary entries with keys that start with the
/// command name followed by an underscore are applied to the command-specific environment; all other entries
/// are applied to the shared environment. The HasChanged property is set to <see langword="true"/> if any
/// command-specific updates are made.</remarks>
/// <param name="dictionary">A dictionary containing key-value pairs to be applied to the environment. Keys prefixed with the command
/// name and an underscore are treated as command-specific updates.</param>
/// <param name="commandName">The name of the command for which command-specific environment updates should be applied. If null or empty,
/// all updates are applied to the shared environment.</param>
/// <remarks>If any updates are made, the HasChanged property is set to <see langword="true"/>.
/// When updating a command-specific environment, changes are logged for traceability.</remarks>
/// <param name="dictionary">A dictionary containing environment variable names and their corresponding values to update. Cannot be null.</param>
/// <param name="commandName">The name of the command whose environment should be updated. If null or empty, the global environment is
/// updated instead.</param>
public void UpdateEnvironment(Dictionary<string, string> dictionary, string commandName)
{
if (String.IsNullOrEmpty(commandName))
Expand All @@ -232,39 +262,23 @@ public void UpdateEnvironment(Dictionary<string, string> dictionary, string comm
return;
}

var commandPrefix = string.Concat(commandName, "_");
var sharedEnvironmentUpdates = new Dictionary<string, string>();
var prefix = commandName + "_";
var commandEnvironment = _commandEnvironment.GetOrAdd(commandName, new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase));
var commandEnvironmentUpdated = false;

foreach ((var key, var addValue) in dictionary)
{
if (key.StartsWith(commandPrefix, StringComparison.OrdinalIgnoreCase))
{
string? oldValue = null;
commandEnvironment.AddOrUpdate(key, addValue, (environmentKey, existingValue) =>
{
oldValue = existingValue;
Trace.WriteLine($"CommandEnvironment [{commandName}] value {environmentKey} changed from {existingValue} to {addValue}.");
return addValue;
});
commandEnvironmentUpdated = true;
}
else
var indexKey = (key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) ?
key.Substring(prefix.Length) :
key;

commandEnvironment.AddOrUpdate(indexKey, addValue, (environmentKey, existingValue) =>
{
sharedEnvironmentUpdates[key] = addValue;
}
Trace.WriteLine($"CommandEnvironment [{commandName}] value {environmentKey} changed from {existingValue} to {addValue}.");
return addValue;
});
}

if (sharedEnvironmentUpdates.Count > 0)
{
_environment.UpdateEnvironment(sharedEnvironmentUpdates);
}

if (commandEnvironmentUpdated)
{
HasChanged = true;
}
HasChanged = dictionary.Count > 0;
}

/// <summary>
Expand Down
1 change: 0 additions & 1 deletion src/Xcaciv.Command/PipelineExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ public PipelineExecutor()
{
}


public async Task ExecuteAsync(
string commandLine,
IIoContext ioContext,
Expand Down
2 changes: 1 addition & 1 deletion src/Xcaciv.Command/Xcaciv.Command.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.3.2</Version>
<Version>3.3.4</Version>
<AssemblyName>Xcaciv.Command</AssemblyName>
<RootNamespace>Xcaciv.Command</RootNamespace>
<IsPublishable>True</IsPublishable>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,7 @@ public Dictionary<string, string> GetEnvironment()
return new Dictionary<string, string>(_globalEnvironment);
}

public Dictionary<string, string> GetEnvironment(string commandName)
public Dictionary<string, string> GetEnvironment(string commandName, bool prefix = true)
{
return _commandEnvironments.TryGetValue(commandName, out var env)
? new Dictionary<string, string>(env)
Expand Down
Loading
Loading