diff --git a/README.md b/README.md index b809b5d..07ccaa7 100644 --- a/README.md +++ b/README.md @@ -785,7 +785,60 @@ Use cases: - **Generated documentation** — pipe into a docs generator or templating step to keep reference docs in sync without manual maintenance. - **CI validation** — diff `cli-schema.json` across commits to catch unintentional breaking changes to the CLI surface. -The shape is defined by [`ArghCliSchemaDocument`](src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs). Output is indented camelCase JSON. Reserved meta-commands (`__complete`, `__completion`, `__schema`) appear under `reservedMetaCommands`. +The shape is defined by [`ArghCliSchemaDocument`](src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs) and conforms to the [cli-schema v1 specification](https://github.com/cli-schema/cli-schema). Output is indented camelCase JSON. Reserved meta-commands (`__complete`, `__completion`, `__schema`) appear under `reservedMetaCommands`. + +### Schema enrichment attributes + +Add `using Nullean.Argh.Documentation;` to access the attributes below. They have no effect on parsing or validation — they only enrich the `__schema` output for agent tooling and documentation consumers. + +**Side-effect profile** (`intent` object): + +```csharp +[CommandIntent(Intent.Destructive | Intent.RequiresConfirmation)] +[MutationScope(MutationScope.Global)] // File | Directory | Global +[RequiresAuth] +public static Task Delete([ConfirmationSkip] bool yes = false, ...) { } +``` + +`Intent` is a flags enum — combine with `|`. `MutationScope.Global` means the command reaches beyond the local filesystem (cloud resources, databases, registries, etc.). `[RequiresAuth]` signals that an authenticated session is required. `[ConfirmationSkip]` on a flag parameter sets its schema `role` to `"confirmationSkip"` so agent consumers know to pass it automatically on destructive commands. `[DryRun]` sets `role` to `"dryRun"`. + +**Output formats** (`output` object): + +```csharp +// Enum parameter — formats and formatFlag inferred automatically +public static void Report([CommandOutput] OutputFormat? format = null) { } + +// Explicit format list on a string parameter +public static void Export([CommandOutput("json", "table")] string? fmt = null) { } +``` + +Place `[CommandOutput]` on the parameter (or `[AsParameters]` DTO property, or GlobalOptions property) that selects the output format. The flag name and format list are derived from the parameter — no extra arguments needed for enum types. + +**Deprecated commands and parameters** (`deprecated` object): + +```csharp +[Obsolete("Use new-cmd instead.")] +public static void OldCmd(...) { } +``` + +`[Obsolete]` on a handler method or an `[AsParameters]` DTO property emits a `deprecated` object in the schema. The message, if provided, appears as `deprecated.message`. + +**Environment variables and config files** (`environment` object): + +```csharp +builder.DocumentEnvironmentVariables( + variables: + [ + new CliEnvVar("GITHUB_TOKEN", Description: "GitHub API token", Required: true), + new CliEnvVar("XDG_CONFIG_HOME", Description: "Config directory override"), + ], + configFiles: + [ + new CliConfigFile("~/.config/myapp/config.json", Description: "Main config"), + ]); +``` + +Arguments must be `new CliEnvVar(...)` / `new CliConfigFile(...)` object creation expressions with string/bool literals so the source generator can extract them statically. **Native AOT in CI:** The GitHub Actions workflow runs an **`aot-validate`** job that publishes [`examples/ArghAotSmoketest`](examples/ArghAotSmoketest) with Native AOT on Linux, macOS, and Windows and invokes `__schema` on the native binary. That sample uses `Microsoft.Extensions.Hosting` and `AddArgh` so **`Map` / `MapNamespace` DI registration** is included in the AOT publish. The repo uses the SDK unified artifacts layout (output under **`.artifacts/`**, gitignored). diff --git a/schema/argh-cli-schema.json b/schema/argh-cli-schema.json index 2789c87..8497007 100644 --- a/schema/argh-cli-schema.json +++ b/schema/argh-cli-schema.json @@ -123,6 +123,36 @@ "type": "boolean", "default": false }, + "deprecated": { + "type": [ + "object", + "null" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ], + "default": null + }, + "since": { + "type": [ + "string", + "null" + ], + "default": null + }, + "removedIn": { + "type": [ + "string", + "null" + ], + "default": null + } + }, + "default": null + }, "validations": { "type": [ "array", @@ -328,6 +358,36 @@ }, "default": null }, + "deprecated": { + "type": [ + "object", + "null" + ], + "properties": { + "message": { + "type": [ + "string", + "null" + ], + "default": null + }, + "since": { + "type": [ + "string", + "null" + ], + "default": null + }, + "removedIn": { + "type": [ + "string", + "null" + ], + "default": null + } + }, + "default": null + }, "intent": { "type": [ "object", @@ -553,6 +613,86 @@ ] }, "default": null + }, + "environment": { + "type": [ + "object", + "null" + ], + "properties": { + "variables": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "object", + "null" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ], + "default": null + }, + "required": { + "type": "boolean", + "default": false + }, + "defaultValue": { + "type": [ + "string", + "null" + ], + "default": null + } + }, + "required": [ + "name" + ] + }, + "default": null + }, + "configFiles": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "object", + "null" + ], + "properties": { + "path": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ], + "default": null + }, + "required": { + "type": "boolean", + "default": false + } + }, + "required": [ + "path" + ] + }, + "default": null + } + }, + "default": null } }, "required": [ diff --git a/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs b/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs index b443811..7136df4 100644 --- a/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs +++ b/src/Nullean.Argh.Core/ArghApp.IArghBuilder.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Nullean.Argh.Builder; +using Nullean.Argh.Documentation; using Nullean.Argh.Middleware; namespace Nullean.Argh; @@ -34,6 +35,13 @@ IArghRootBuilder IArghRootBuilder.UseCliDescription(string description) return this; } + /// + IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVar[]? variables, CliConfigFile[]? configFiles) + { + _ = DocumentEnvironmentVariables(variables, configFiles); + return this; + } + /// IArghBuilder IArghBuilder.MapRoot(Delegate handler) { diff --git a/src/Nullean.Argh.Core/ArghApp.cs b/src/Nullean.Argh.Core/ArghApp.cs index 99380f9..799cee7 100644 --- a/src/Nullean.Argh.Core/ArghApp.cs +++ b/src/Nullean.Argh.Core/ArghApp.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Nullean.Argh.Builder; +using Nullean.Argh.Documentation; using Nullean.Argh.Help; using Nullean.Argh.Runtime; @@ -33,6 +34,9 @@ public ArghApp Map(string name, Delegate handler) /// Sets a one-line description shown in root --help output. Analyzed by the source generator; no-op at runtime. public ArghApp UseCliDescription(string description) => this; + /// Documents environment variables and config files the program reads. Analyzed by the source generator; no-op at runtime. + public ArghApp DocumentEnvironmentVariables(CliEnvVar[]? variables = null, CliConfigFile[]? configFiles = null) => this; + /// /// Registers a default handler when no subcommand or namespace segment applies at the current scope /// (app root or inside a block). Analyzed by the source generator. diff --git a/src/Nullean.Argh.Core/Builder/ArghBuilder.cs b/src/Nullean.Argh.Core/Builder/ArghBuilder.cs index 23a7784..d389716 100644 --- a/src/Nullean.Argh.Core/Builder/ArghBuilder.cs +++ b/src/Nullean.Argh.Core/Builder/ArghBuilder.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Nullean.Argh; +using Nullean.Argh.Documentation; using Nullean.Argh.Middleware; using Nullean.Argh.Runtime; @@ -50,6 +51,15 @@ public IArghRootBuilder UseCliDescription(string description) return this; } + /// + public IArghRootBuilder DocumentEnvironmentVariables( + CliEnvVar[]? variables = null, + CliConfigFile[]? configFiles = null) + { + _ = _app.DocumentEnvironmentVariables(variables, configFiles); + return this; + } + /// public IArghBuilder MapRoot(Delegate handler) { diff --git a/src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs b/src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs index 48b815b..20e8984 100644 --- a/src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs +++ b/src/Nullean.Argh.Core/Schema/ArghCliSchemaDocument.cs @@ -18,7 +18,9 @@ public sealed record ArghCliSchemaDocument( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? RequiresAuth = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - string[]? AuthCommands = null); + string[]? AuthCommands = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliEnvironmentSchema? Environment = null); /// Nested command namespace (subcommand group). public sealed record CliNamespaceSchema( @@ -46,6 +48,8 @@ public sealed record CliCommandSchema( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string[]? Tags = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliDeprecationSchema? Deprecated = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] CliIntentSchema? Intent = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] CliOutputSchema? Output = null, @@ -86,7 +90,7 @@ public sealed record CliDefaultHandlerSchema( bool Hidden = false); /// CLI flag or positional parameter description. -/// flag or positional. +/// flag, positional, confirmationSkip, or dryRun. /// JSON Schema primitive: string, integer, number, boolean, array, or enum. public sealed record CliParameterSchema( string Role, @@ -112,6 +116,8 @@ public sealed record CliParameterSchema( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] bool Variadic = false, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliDeprecationSchema? Deprecated = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] CliConstraintSchema[]? Validations = null); /// A single validation constraint on a CLI parameter. @@ -122,3 +128,37 @@ public sealed record CliConstraintSchema( string? Max = null, string? Pattern = null, string[]? Values = null); + +/// Structured deprecation metadata for a command or parameter. +public sealed record CliDeprecationSchema( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Message = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Since = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? RemovedIn = null); + +/// External context the program depends on (env vars and config files). +public sealed record CliEnvironmentSchema( + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliEnvVarSchema[]? Variables = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + CliConfigFileSchema[]? ConfigFiles = null); + +/// An environment variable the program reads. +public sealed record CliEnvVarSchema( + string Name, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Description = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + bool Required = false, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? DefaultValue = null); + +/// A configuration file the program reads. +public sealed record CliConfigFileSchema( + string Path, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? Description = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + bool Required = false); diff --git a/src/Nullean.Argh.Core/Schema/ArghCliSchemaJsonContext.cs b/src/Nullean.Argh.Core/Schema/ArghCliSchemaJsonContext.cs index cf70674..64971a3 100644 --- a/src/Nullean.Argh.Core/Schema/ArghCliSchemaJsonContext.cs +++ b/src/Nullean.Argh.Core/Schema/ArghCliSchemaJsonContext.cs @@ -10,6 +10,12 @@ namespace Nullean.Argh.Schema; [JsonSerializable(typeof(CliParameterSchema))] [JsonSerializable(typeof(CliIntentSchema))] [JsonSerializable(typeof(CliOutputSchema))] +[JsonSerializable(typeof(CliDeprecationSchema))] +[JsonSerializable(typeof(CliEnvironmentSchema))] +[JsonSerializable(typeof(CliEnvVarSchema))] +[JsonSerializable(typeof(CliConfigFileSchema))] +[JsonSerializable(typeof(CliEnvVarSchema[]))] +[JsonSerializable(typeof(CliConfigFileSchema[]))] [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(CliParameterSchema[]))] [JsonSerializable(typeof(CliCommandSchema[]))] diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs b/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs index 308d018..dbc4b3f 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.Schema.cs @@ -36,6 +36,7 @@ private static void EmitBuildCliSchemaDocumentHierarchical(StringBuilder sb, App EmitSchemaRootCommandsExpression(sb, app, entryAssemblyName, "\t\t\t\t"); sb.AppendLine(","); EmitSchemaNamespacesExpression(sb, app.Root.Children, entryAssemblyName, "\t\t\t\t"); + EmitSchemaEnvironmentArg(sb, app, "\t\t\t\t"); sb.AppendLine(");"); } @@ -167,6 +168,34 @@ private static void EmitCliCommandSchemaBody(StringBuilder sb, CommandModel cmd, sb.AppendLine(","); sb.Append($"{indent}\tHidden: true"); } + if (cmd.IsDeprecated) + { + sb.AppendLine(","); + if (cmd.DeprecationMessage is not null) + sb.Append($"{indent}\tDeprecated: new CliDeprecationSchema(Message: \"{Escape(cmd.DeprecationMessage)}\")"); + else + sb.Append($"{indent}\tDeprecated: new CliDeprecationSchema()"); + } + if (cmd.Intent is { } intent) + { + sb.AppendLine(","); + sb.Append($"{indent}\tIntent: new CliIntentSchema("); + var intentParts = new System.Collections.Generic.List(); + if (intent.Destructive is bool d) intentParts.Add($"Destructive: {(d ? "true" : "false")}"); + if (intent.Idempotent is bool i) intentParts.Add($"Idempotent: {(i ? "true" : "false")}"); + if (intent.Scope is string sc) intentParts.Add($"Scope: \"{Escape(sc)}\""); + if (intent.RequiresConfirmation is bool rc) intentParts.Add($"RequiresConfirmation: {(rc ? "true" : "false")}"); + if (intent.RequiresAuth is bool ra) intentParts.Add($"RequiresAuth: {(ra ? "true" : "false")}"); + sb.Append(string.Join(", ", intentParts)); + sb.Append(")"); + } + if (cmd.Output is { } output && !output.Formats.IsDefaultOrEmpty) + { + sb.AppendLine(","); + var fmts = string.Join(", ", output.Formats.Select(f => $"\"{Escape(f)}\"")); + var fflag = output.FormatFlag is not null ? $", FormatFlag: \"{Escape(output.FormatFlag)}\"" : ""; + sb.Append($"{indent}\tOutput: new CliOutputSchema(Formats: new string[] {{ {fmts} }}{fflag})"); + } sb.AppendLine(); sb.Append(indent); sb.Append(")"); @@ -348,9 +377,50 @@ private static void EmitExamplesStringArray(StringBuilder sb, string? examplesRe sb.Append("}"); } + private static void EmitSchemaEnvironmentArg(StringBuilder sb, AppEmitModel app, string indent) + { + var hasVars = !app.EnvironmentVars.IsDefaultOrEmpty; + var hasCfgs = !app.ConfigFiles.IsDefaultOrEmpty; + if (!hasVars && !hasCfgs) return; + + sb.AppendLine(","); + sb.AppendLine(indent + "Environment: new CliEnvironmentSchema("); + if (hasVars) + { + sb.AppendLine(indent + "\tVariables: new CliEnvVarSchema[]"); + sb.AppendLine(indent + "\t{"); + foreach (var v in app.EnvironmentVars) + { + var desc = v.Description is not null ? $", Description: \"{Escape(v.Description)}\"" : ""; + var req = v.Required ? ", Required: true" : ""; + var defVal = v.DefaultValue is not null ? $", DefaultValue: \"{Escape(v.DefaultValue)}\"" : ""; + sb.AppendLine($"{indent}\t\tnew CliEnvVarSchema(\"{Escape(v.Name)}\"{desc}{req}{defVal}),"); + } + sb.Append(indent + "\t}"); + if (hasCfgs) sb.AppendLine(","); + } + if (hasCfgs) + { + sb.AppendLine(indent + "\tConfigFiles: new CliConfigFileSchema[]"); + sb.AppendLine(indent + "\t{"); + foreach (var c in app.ConfigFiles) + { + var desc = c.Description is not null ? $", Description: \"{Escape(c.Description)}\"" : ""; + var req = c.Required ? ", Required: true" : ""; + sb.AppendLine($"{indent}\t\tnew CliConfigFileSchema(\"{Escape(c.Path)}\"{desc}{req}),"); + } + sb.Append(indent + "\t}"); + } + sb.AppendLine(); + sb.Append(indent + ")"); + } + private static string EmitCliParameterSchemaNewExpression(ParameterModel p) { - var role = p.Kind == ParameterKind.Positional ? "positional" : "flag"; + var role = p.IsConfirmationSkip ? "confirmationSkip" + : p.IsDryRun ? "dryRun" + : p.Kind == ParameterKind.Positional ? "positional" + : "flag"; var shortName = p.ShortOpt is char c ? $"\"{Escape(c.ToString())}\"" : "null"; var type = MapToJsonSchemaType(p.ScalarKind, p.TypeName); var req = p.IsRequired ? "true" : "false"; @@ -395,6 +465,14 @@ private static string EmitCliParameterSchemaNewExpression(ParameterModel p) if (p.IsVariadic) sb.Append(", Variadic: true"); + if (p.IsDeprecated) + { + if (p.DeprecationMessage is not null) + sb.Append($", Deprecated: new CliDeprecationSchema(Message: \"{Escape(p.DeprecationMessage)}\")"); + else + sb.Append(", Deprecated: new CliDeprecationSchema()"); + } + var validations = BuildConstraintsExpression(p.Validations, p.ExpandUserProfileBeforeBind); if (validations != "null") sb.Append($", Validations: {validations}"); diff --git a/src/Nullean.Argh.Generator/CliParserGenerator.cs b/src/Nullean.Argh.Generator/CliParserGenerator.cs index 6ddee6b..3de661b 100644 --- a/src/Nullean.Argh.Generator/CliParserGenerator.cs +++ b/src/Nullean.Argh.Generator/CliParserGenerator.cs @@ -313,6 +313,7 @@ private static bool IsTrackedArghInvocation(InvocationExpressionSyntax inv, Memb switch (methodName) { case "UseCliDescription": + case "DocumentEnvironmentVariables": case "MapNamespace": case "MapRoot": case "UseGlobalOptions": @@ -537,6 +538,8 @@ private sealed class AppEmitModel public ImmutableArray AllCommands = ImmutableArray.Empty; public ImmutableArray GlobalMiddleware = ImmutableArray.Empty; public readonly List ArglessNamespaceCodegen = new(); + public ImmutableArray EnvironmentVars = ImmutableArray.Empty; + public ImmutableArray ConfigFiles = ImmutableArray.Empty; /// Pre-computed injection chains per command (keyed by ). Set once in TryBuildAppEmitModel after AllCommands is populated. public ImmutableDictionary AllBaseTypeMetadataNames, string StaticFieldName, string LocalVarName, ImmutableArray FlatMembers, ImmutableArray? BestCtorParamOrder)>> InjectionChains = ImmutableDictionary, string, string, ImmutableArray, ImmutableArray?)>>.Empty; @@ -653,6 +656,26 @@ private sealed record AIUseMiddleware(string FilePath, int SpanStart, GlobalMidd private sealed record AIUseCliDescription(string FilePath, int SpanStart, string Description) : AnalyzedInvocation(FilePath, SpanStart); + /// A DocumentEnvironmentVariables(...) invocation — only meaningful at root scope. + private sealed record AIDocumentEnvironmentVariables( + string FilePath, + int SpanStart, + ImmutableArray Variables, + ImmutableArray ConfigFiles) + : AnalyzedInvocation(FilePath, SpanStart); + + /// Symbol-free representation of a passed to DocumentEnvironmentVariables. + private sealed record EnvVarDocEntry(string Name, string? Description, bool Required, string? DefaultValue); + + /// Symbol-free representation of a passed to DocumentEnvironmentVariables. + private sealed record ConfigFileDocEntry(string Path, string? Description, bool Required); + + /// Symbol-free intent data extracted from [CommandIntent]. + private sealed record CommandIntentData(bool? Destructive, bool? Idempotent, string? Scope, bool? RequiresConfirmation, bool? RequiresAuth); + + /// Symbol-free output data extracted from [CommandOutput]. + private sealed record CommandOutputData(ImmutableArray Formats, string? FormatFlag); + /// /// An Add<T>() or Add(name, handler) invocation. /// For Add<T>, holds the full registry structure; @@ -876,6 +899,8 @@ public ImmutableArray ToImmutable() => var desc = TryGetStringLiteral(descExpr) ?? ""; return new AIUseCliDescription(filePath, spanStart, desc); } + case "DocumentEnvironmentVariables": + return AnalyzeDocumentEnvironmentVariables(invocation, filePath, spanStart); case "MapRoot": { if (invocation.ArgumentList.Arguments.Count < 1) return null; @@ -1159,6 +1184,16 @@ private static bool TryBuildAppEmitModel( } } + foreach (var ai in rootAnalyzed) + { + if (ai is AIDocumentEnvironmentVariables { Variables: var vars, ConfigFiles: var cfgs }) + { + if (!vars.IsDefaultOrEmpty) app.EnvironmentVars = vars; + if (!cfgs.IsDefaultOrEmpty) app.ConfigFiles = cfgs; + break; + } + } + ProcessAnalyzedInvocationsForNode(context, sorted, rootAnalyzed, app.Root, ImmutableArray.Empty, app, isRoot: true); if (!string.IsNullOrWhiteSpace(app.RootSummary) && app.Root.RootCommand is not null) @@ -2433,6 +2468,211 @@ private static ImmutableArray TryGetCommandAliasesFromAttribute(IMethodS return ImmutableArray.Empty; } + private static (bool IsDeprecated, string? Message) TryGetObsoleteAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name is "ObsoleteAttribute" or "Obsolete" && + (ad.AttributeClass.ContainingNamespace?.ToDisplayString() is "System" or "")) + { + var msg = ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is string s && !string.IsNullOrWhiteSpace(s) + ? s + : null; + return (true, msg); + } + } + + return (false, null); + } + + private const string DocNs = "Nullean.Argh.Documentation"; + + private static CommandIntentData? TryGetCommandIntentData(IMethodSymbol method) + { + bool? destructive = null, idempotent = null, requiresConfirmation = null, requiresAuth = null; + string? scope = null; + + foreach (var ad in method.GetAttributes()) + { + if (ad.AttributeClass?.ContainingNamespace?.ToDisplayString() != DocNs) continue; + + switch (ad.AttributeClass.Name) + { + case "CommandIntentAttribute": + { + // Constructor arg 0 is the Intent flags enum (underlying int) + // Destructive=1, Idempotent=2, RequiresConfirmation=4 + var flagsInt = 0; + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int f) + flagsInt = f; + if ((flagsInt & 1) != 0) destructive = true; + if ((flagsInt & 2) != 0) idempotent = true; + if ((flagsInt & 4) != 0) requiresConfirmation = true; + break; + } + case "MutationScopeAttribute": + { + // Constructor arg 0 is MutationScope enum: 0=File, 1=Directory, 2=Global + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Value is int s) + scope = s switch { 0 => "file", 1 => "directory", 2 => "global", _ => null }; + break; + } + case "RequiresAuthAttribute": + requiresAuth = true; + break; + } + } + + if (destructive is null && idempotent is null && requiresConfirmation is null && requiresAuth is null && scope is null) + return null; + return new CommandIntentData(destructive, idempotent, scope, requiresConfirmation, requiresAuth); + } + + private static (bool IsOutput, ImmutableArray ExplicitFormats) TryGetCommandOutputAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name == "CommandOutputAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) + { + var formats = ImmutableArray.Empty; + if (ad.ConstructorArguments.Length >= 1 && ad.ConstructorArguments[0].Kind == TypedConstantKind.Array) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (var v in ad.ConstructorArguments[0].Values) + { + if (v.Value is string s && !string.IsNullOrWhiteSpace(s)) + builder.Add(s); + } + formats = builder.ToImmutable(); + } + return (true, formats); + } + } + return (false, ImmutableArray.Empty); + } + + private static bool HasConfirmationSkipAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name == "ConfirmationSkipAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) + return true; + } + return false; + } + + private static bool HasDryRunAttribute(ISymbol symbol) + { + foreach (var ad in symbol.GetAttributes()) + { + if (ad.AttributeClass?.Name == "DryRunAttribute" && + ad.AttributeClass.ContainingNamespace?.ToDisplayString() == DocNs) + return true; + } + return false; + } + + private static CommandOutputData? BuildCommandOutputFromParameters(ImmutableArray parameters) + { + foreach (var p in parameters) + { + if (!p.IsCommandOutput) continue; + var flagName = "--" + p.CliLongName; + ImmutableArray formats; + if (!p.CommandOutputExplicitFormats.IsDefaultOrEmpty) + formats = p.CommandOutputExplicitFormats; + else if (p.ScalarKind == CliScalarKind.Enum && !p.EnumMemberNames.IsDefaultOrEmpty) + { + // Resolve CLI names the same way the help/schema emitter does + var builder = ImmutableArray.CreateBuilder(p.EnumMemberNames.Length); + for (var i = 0; i < p.EnumMemberNames.Length; i++) + builder.Add(ResolveEnumMemberCliName(p.EnumMemberCliNames, i, p.EnumMemberNames[i])); + formats = builder.ToImmutable(); + } + else + formats = ImmutableArray.Empty; + return new CommandOutputData(formats, flagName); + } + return null; + } + + private static AIDocumentEnvironmentVariables? AnalyzeDocumentEnvironmentVariables( + InvocationExpressionSyntax invocation, string filePath, int spanStart) + { + var varsBuilder = ImmutableArray.CreateBuilder(); + var cfgBuilder = ImmutableArray.CreateBuilder(); + + foreach (var arg in invocation.ArgumentList.Arguments) + { + var nameColon = arg.NameColon?.Name.Identifier.Text; + + if (arg.Expression is not (ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax)) + continue; + + ArgumentListSyntax? ctorArgs = arg.Expression switch + { + ObjectCreationExpressionSyntax o => o.ArgumentList, + ImplicitObjectCreationExpressionSyntax i => i.ArgumentList, + _ => null + }; + if (ctorArgs is null) continue; + + // Determine type from name colon or array element pattern + var typeName = arg.Expression is ObjectCreationExpressionSyntax oce + ? oce.Type.ToString() + : null; + bool isConfigFile = typeName?.Contains("ConfigFile") == true || nameColon == "configFiles"; + + if (isConfigFile) + { + var path = ctorArgs.Arguments.Count >= 1 + ? TryGetStringLiteral(ctorArgs.Arguments[0].Expression) + : null; + if (path is null) continue; + string? desc = null; + bool req = false; + foreach (var ca in ctorArgs.Arguments) + { + var n = ca.NameColon?.Name.Identifier.Text; + if (n == "Description") desc = TryGetStringLiteral(ca.Expression); + if (n == "Required") req = TryGetBoolLiteral(ca.Expression) ?? false; + } + cfgBuilder.Add(new ConfigFileDocEntry(path, desc, req)); + } + else + { + var name = ctorArgs.Arguments.Count >= 1 + ? TryGetStringLiteral(ctorArgs.Arguments[0].Expression) + : null; + if (name is null) continue; + string? desc = null; + bool req = false; + string? defVal = null; + foreach (var ca in ctorArgs.Arguments) + { + var n = ca.NameColon?.Name.Identifier.Text; + if (n == "Description") desc = TryGetStringLiteral(ca.Expression); + if (n == "Required") req = TryGetBoolLiteral(ca.Expression) ?? false; + if (n == "DefaultValue") defVal = TryGetStringLiteral(ca.Expression); + } + varsBuilder.Add(new EnvVarDocEntry(name, desc, req, defVal)); + } + } + + if (varsBuilder.Count == 0 && cfgBuilder.Count == 0) return null; + return new AIDocumentEnvironmentVariables(filePath, spanStart, varsBuilder.ToImmutable(), cfgBuilder.ToImmutable()); + } + + private static bool? TryGetBoolLiteral(ExpressionSyntax expr) => + expr.Kind() switch + { + Microsoft.CodeAnalysis.CSharp.SyntaxKind.TrueLiteralExpression => true, + Microsoft.CodeAnalysis.CSharp.SyntaxKind.FalseLiteralExpression => false, + _ => null + }; + private static ExpressionSyntax? TryGetPropertyInitializerValueSyntax(IPropertySymbol prop) { foreach (var syntaxRef in prop.DeclaringSyntaxReferences) @@ -8849,7 +9089,11 @@ private sealed record CommandModel( string LambdaDelegateFq = "", bool IsIntrinsic = false, ImmutableArray CommandAliases = default, - bool IsHidden = false) + bool IsHidden = false, + bool IsDeprecated = false, + string? DeprecationMessage = null, + CommandIntentData? Intent = null, + CommandOutputData? Output = null) { public static CommandModel FromRootMethod( IMethodSymbol method, @@ -8952,6 +9196,7 @@ public static CommandModel FromMethod( HasPublicParameterlessCtor(namedCt); var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = ExtractHandlerAnalysis(method); + var (isObs, obsMsg) = TryGetObsoleteAttribute(method); return new CommandModel( routePrefix, commandName, @@ -8978,7 +9223,11 @@ public static CommandModel FromMethod( mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), - IsHidden: HasHiddenAttribute(method)); + IsHidden: HasHiddenAttribute(method), + IsDeprecated: isObs, + DeprecationMessage: obsMsg, + Intent: TryGetCommandIntentData(method), + Output: BuildCommandOutputFromParameters(withDocs)); } /// Overload for the per-invocation Select step — uses instead of SourceProductionContext. @@ -9047,7 +9296,8 @@ public static CommandModel FromMethod( var containingFq = method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var hasParamlessCtor = method.ContainingType is INamedTypeSymbol namedCt && HasPublicParameterlessCtor(namedCt); var (retFq, retIsAsync, retIsVoid, handlerNoInj, handlerParams, handlerLoc, ctorParams, mwData, docId) = ExtractHandlerAnalysis(method); - return new CommandModel(routePrefix, commandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), IsHidden: HasHiddenAttribute(method)); + var (isDeprecated, deprecationMsg) = TryGetObsoleteAttribute(method); + return new CommandModel(routePrefix, commandName, runName, containingFq, method.Name, !method.IsStatic, hasParamlessCtor, retFq, retIsAsync, retIsVoid, withDocs, handlerNoInj, handlerParams, handlerLoc, ctorParams, docId, docs.SummaryOneLiner, docs.RemarksRendered, docs.SummaryInnerXml, docs.RemarksInnerXml, docs.ExamplesRendered, usage, mwData, IsIntrinsic: HasCommandIntrinsicAttribute(method), CommandAliases: TryGetCommandAliasesFromAttribute(method), IsHidden: HasHiddenAttribute(method), IsDeprecated: isDeprecated, DeprecationMessage: deprecationMsg, Intent: TryGetCommandIntentData(method), Output: BuildCommandOutputFromParameters(withDocs)); } private static ImmutableArray BuildParameterModels( @@ -9416,7 +9666,13 @@ private sealed record ParameterModel( /// Used by to emit string? even when /// is true, preventing CS8600 when the runtime default for a nullable property is null. /// - bool IsNullableAnnotated = false) + bool IsNullableAnnotated = false, + bool IsConfirmationSkip = false, + bool IsDryRun = false, + bool IsCommandOutput = false, + ImmutableArray CommandOutputExplicitFormats = default, + bool IsDeprecated = false, + string? DeprecationMessage = null) { // ── shared helpers ────────────────────────────────────────────────────── @@ -9481,6 +9737,8 @@ private static ParameterModel BuildCollectionParameterModel( // Variadic positionals always allow zero items by C# params convention. // Minimum count enforcement is handled via CollectionCountConstraint ([MinLength]). if (isVariadic) required = false; + var (isOutputColl, outputFormatsColl) = TryGetCommandOutputAttribute(attributeHost); + var (isDeprecatedColl, deprecationMsgColl) = TryGetObsoleteAttribute(attributeHost); return new ParameterModel( symbolName, localVarName, @@ -9520,7 +9778,13 @@ private static ParameterModel BuildCollectionParameterModel( AsParametersClrName: asParams?.ClrName, Validations: collValidations, IsHidden: HasHiddenAttribute(attributeHost), - IsVariadic: isVariadic); + IsVariadic: isVariadic, + IsConfirmationSkip: HasConfirmationSkipAttribute(attributeHost), + IsDryRun: HasDryRunAttribute(attributeHost), + IsCommandOutput: isOutputColl, + CommandOutputExplicitFormats: outputFormatsColl, + IsDeprecated: isDeprecatedColl, + DeprecationMessage: deprecationMsgColl); } // ── five factory methods ───────────────────────────────────────────── @@ -9571,6 +9835,8 @@ public static ParameterModel From(IParameterSymbol p, SourceProductionContext? r var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(p.Type) : default; var validations = ReadValidationConstraints(p, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(p, sk); + var (isOutputP, outputFormatsP) = TryGetCommandOutputAttribute(p); + var (isDeprecatedP, deprecationMsgP) = TryGetObsoleteAttribute(p); return new ParameterModel( p.Name, SafeLocalName(p.Name), @@ -9592,7 +9858,13 @@ public static ParameterModel From(IParameterSymbol p, SourceProductionContext? r EnumMemberDocs: enumDocs, ExpandUserProfileBeforeBind: expandProf, Validations: validations, - IsHidden: HasHiddenAttribute(p)); + IsHidden: HasHiddenAttribute(p), + IsConfirmationSkip: HasConfirmationSkipAttribute(p), + IsDryRun: HasDryRunAttribute(p), + IsCommandOutput: isOutputP, + CommandOutputExplicitFormats: outputFormatsP, + IsDeprecated: isDeprecatedP, + DeprecationMessage: deprecationMsgP); } public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilation? compilation = null, string? defaultValueLiteral = null) @@ -9646,7 +9918,13 @@ public static ParameterModel FromOptionsProperty(IPropertySymbol prop, Compilati IsHidden: HasHiddenAttribute(prop), UsesRuntimeDefault: isCrossAssemblyDefault, IsNullableAnnotated: prop.Type.NullableAnnotation == NullableAnnotation.Annotated - || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }); + || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, + IsConfirmationSkip: HasConfirmationSkipAttribute(prop), + IsDryRun: HasDryRunAttribute(prop), + IsCommandOutput: TryGetCommandOutputAttribute(prop).IsOutput, + CommandOutputExplicitFormats: TryGetCommandOutputAttribute(prop).ExplicitFormats, + IsDeprecated: TryGetObsoleteAttribute(prop).IsDeprecated, + DeprecationMessage: TryGetObsoleteAttribute(prop).Message); } public static ParameterModel FromOptionsField(IFieldSymbol field, Compilation? compilation = null, string? defaultValueLiteral = null) @@ -9779,6 +10057,8 @@ public static ParameterModel FromAsParametersCtorParameter( var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(cp.Type) : default; var validations = ReadValidationConstraints(cp, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(cp, sk); + var (isDeprecatedCp, deprecationMsgCp) = TryGetObsoleteAttribute(cp); + var (isOutputCp, outputFormatsCp) = TryGetCommandOutputAttribute(cp); return new ParameterModel( cp.Name, local, @@ -9803,7 +10083,13 @@ public static ParameterModel FromAsParametersCtorParameter( AsParametersUseInit: false, AsParametersClrName: cp.Name, ExpandUserProfileBeforeBind: expandProf, - Validations: validations); + Validations: validations, + IsConfirmationSkip: HasConfirmationSkipAttribute(cp), + IsDryRun: HasDryRunAttribute(cp), + IsCommandOutput: isOutputCp, + CommandOutputExplicitFormats: outputFormatsCp, + IsDeprecated: isDeprecatedCp, + DeprecationMessage: deprecationMsgCp); } public static ParameterModel FromAsParametersInitProperty( @@ -9877,6 +10163,8 @@ public static ParameterModel FromAsParametersInitProperty( var enumCliNames = sk == CliScalarKind.Enum ? TryGetEnumCliNames(prop.Type) : default; var validations = ReadValidationConstraints(prop, sk, typeName); var expandProf = TryReadExpandUserProfileBeforeBind(prop, sk); + var (isDeprecatedProp, deprecationMsgProp) = TryGetObsoleteAttribute(prop); + var (isOutputProp, outputFormatsProp) = TryGetCommandOutputAttribute(prop); return new ParameterModel( prop.Name, local, @@ -9904,7 +10192,13 @@ public static ParameterModel FromAsParametersInitProperty( Validations: validations, UsesRuntimeDefault: isCrossAssemblyDefault, IsNullableAnnotated: prop.Type.NullableAnnotation == NullableAnnotation.Annotated - || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }); + || prop.Type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }, + IsConfirmationSkip: HasConfirmationSkipAttribute(prop), + IsDryRun: HasDryRunAttribute(prop), + IsCommandOutput: isOutputProp, + CommandOutputExplicitFormats: outputFormatsProp, + IsDeprecated: isDeprecatedProp, + DeprecationMessage: deprecationMsgProp); } private static bool ComputeRequiredForOptionsType(ITypeSymbol type, BoolSpecialKind bs) diff --git a/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs b/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs index 187e592..b0d2e02 100644 --- a/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs +++ b/src/Nullean.Argh.Hosting/ArghHostingBuilder.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Nullean.Argh.Builder; +using Nullean.Argh.Documentation; using Nullean.Argh.Middleware; using Nullean.Argh.Runtime; @@ -150,6 +151,12 @@ IArghRootBuilder IArghRootBuilder.UseCliDescription(string description) return this; } + IArghRootBuilder IArghRootBuilder.DocumentEnvironmentVariables(CliEnvVar[]? variables, CliConfigFile[]? configFiles) + { + _ = _inner.DocumentEnvironmentVariables(variables, configFiles); + return this; + } + /// IArghBuilder IArghBuilder.MapRoot(Delegate handler) { diff --git a/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs b/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs new file mode 100644 index 0000000..527b91b --- /dev/null +++ b/src/Nullean.Argh.Interfaces/Annotations/DocumentationAttributes.cs @@ -0,0 +1,166 @@ +namespace Nullean.Argh.Documentation; + +/// +/// Boolean side-effect flags for . +/// Combine with |: [CommandIntent(Intent.Destructive | Intent.RequiresConfirmation)]. +/// +[Flags] +public enum Intent +{ + /// No flags set. + None = 0, + /// The command deletes, overwrites, or irreversibly modifies data or resources. + Destructive = 1 << 0, + /// Calling the command multiple times produces the same result as calling it once. + Idempotent = 1 << 1, + /// The command blocks on an interactive stdin prompt when run without a confirmationSkip-role parameter. + RequiresConfirmation = 1 << 2, +} + +/// +/// Mutation scope of a command — declares the blast radius of its side effects. +/// Used with . +/// +public enum MutationScope +{ + /// Affects a single file or path. + File, + /// Affects a directory tree. + Directory, + /// + /// Reaches beyond the local filesystem — e.g. writes to a cloud service, database, registry, + /// message queue, or any shared remote state. + /// + Global, +} + +/// +/// Declares the side-effect profile of a command for agent-reasoning consumers. +/// Emitted as the intent object in __schema output. +/// Has no effect on parsing or validation. +/// +/// +/// [CommandIntent(Intent.Destructive | Intent.RequiresConfirmation)] +/// [MutationScope(MutationScope.Global)] +/// [RequiresAuth] +/// public static Task Delete([ConfirmationSkip] bool yes = false) { ... } +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class CommandIntentAttribute : Attribute +{ + public CommandIntentAttribute(Intent flags = Intent.None) => Flags = flags; + + /// Boolean side-effect flags (combine with |). + public Intent Flags { get; } +} + +/// +/// Declares the mutation scope of a command — the blast radius of its side effects. +/// Emitted as intent.scope in __schema output. +/// Has no effect on parsing or validation. +/// +/// +/// +/// — affects a single file or path. +/// — affects a directory tree. +/// — reaches beyond the local filesystem +/// (cloud resources, databases, registries, network services, etc.). +/// +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class MutationScopeAttribute : Attribute +{ + public MutationScopeAttribute(MutationScope scope) => Scope = scope; + + /// The mutation scope of the command. + public MutationScope Scope { get; } +} + +/// +/// Marks a command as requiring an authenticated session. +/// Emitted as intent.requiresAuth: true in __schema output. +/// Has no effect on parsing or validation. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class RequiresAuthAttribute : Attribute; + +/// +/// Marks a parameter or property as a confirmation-skip signal (e.g. --yes, --force). +/// Emits role: "confirmationSkip" in __schema so agent consumers know to pass this flag +/// automatically on destructive commands when running non-interactively. +/// Has no effect on parsing or validation. +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class ConfirmationSkipAttribute : Attribute; + +/// +/// Marks a parameter or property as a dry-run signal (e.g. --dry-run, --whatif). +/// Emits role: "dryRun" in __schema so agent consumers know to pass this flag +/// when they want to preview effects without committing side effects. +/// Has no effect on parsing or validation. +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class DryRunAttribute : Attribute; + +/// +/// Marks a parameter or property as the command's output-format selector. +/// Emits the output object in __schema, with formatFlag derived from the +/// parameter's CLI name and formats from the enum's CLI values (or the explicit list). +/// Has no effect on parsing or validation. +/// +/// +/// Apply to an enum-typed parameter or property to infer formats automatically: +/// public static void Report([CommandOutput] OutputFormat? format = null) { ... } +/// +/// Or supply an explicit list for string-typed parameters: +/// public static void Report([CommandOutput("json", "table")] string? format = null) { ... } +/// +/// Also works on [AsParameters] DTO properties and on GlobalOptions / NamespaceOptions properties. +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class CommandOutputAttribute : Attribute +{ + /// Explicit output format names. When empty and the parameter is enum-typed, formats are inferred from the enum's CLI values. + public string[] Formats { get; } + + public CommandOutputAttribute(params string[] formats) => Formats = formats; +} + +/// Describes an environment variable the program reads. Used with . +public sealed class CliEnvVar +{ + public CliEnvVar(string name, string? description = null, bool required = false, string? defaultValue = null) + { + Name = name; + Description = description; + Required = required; + DefaultValue = defaultValue; + } + + /// Variable name (e.g. "GITHUB_TOKEN"). + public string Name { get; } + /// What the variable controls. + public string? Description { get; } + /// Whether the program requires this variable to function. + public bool Required { get; } + /// Default value if not set. + public string? DefaultValue { get; } +} + +/// Describes a configuration file the program reads. Used with . +public sealed class CliConfigFile +{ + public CliConfigFile(string path, string? description = null, bool required = false) + { + Path = path; + Description = description; + Required = required; + } + + /// File path (~ is expanded to the user's home directory). + public string Path { get; } + /// What the file controls. + public string? Description { get; } + /// Whether the program requires this file to exist. + public bool Required { get; } +} diff --git a/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs b/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs index 69ad552..21f83c0 100644 --- a/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs +++ b/src/Nullean.Argh.Interfaces/Builder/IArghRootBuilder.cs @@ -1,3 +1,5 @@ +using Nullean.Argh.Documentation; + namespace Nullean.Argh.Builder; /// @@ -10,4 +12,14 @@ public interface IArghRootBuilder : IArghBuilder /// Cannot be combined with MapRoot; the generator reports AGH0023 if both are present. /// IArghRootBuilder UseCliDescription(string description); + + /// + /// Documents environment variables and optional configuration files the program reads. + /// Analyzed by the source generator and emitted as the environment object in __schema output. + /// Arguments must be new CliEnvVar(...) or new CliConfigFile(...) object creation + /// expressions with string/bool literals so the generator can extract them statically. + /// + IArghRootBuilder DocumentEnvironmentVariables( + CliEnvVar[]? variables = null, + CliConfigFile[]? configFiles = null); } diff --git a/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs b/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs index cba07bd..3bcc68c 100644 --- a/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Completions/SchemaTests.cs @@ -239,4 +239,102 @@ public void Schema_enum_parameter_has_type_enum_and_enum_values() enumParam.ValueKind.Should().Be(JsonValueKind.Object); enumParam.GetProperty("enumValues").EnumerateArray().Should().NotBeEmpty(); } + + [Fact] + public void Schema_deprecated_command_without_message_emits_deprecated_object() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-deprecated-simple"); + cmd.ValueKind.Should().Be(JsonValueKind.Object); + cmd.TryGetProperty("deprecated", out var dep).Should().BeTrue(); + dep.ValueKind.Should().Be(JsonValueKind.Object); + } + + [Fact] + public void Schema_deprecated_command_with_message_emits_deprecated_message() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-deprecated-with-message"); + cmd.ValueKind.Should().Be(JsonValueKind.Object); + cmd.TryGetProperty("deprecated", out var dep).Should().BeTrue(); + dep.GetProperty("message").GetString().Should().Contain("schema-deprecated-replacement"); + } + + [Fact] + public void Schema_deprecated_parameter_emits_deprecated_with_message() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-deprecated-param"); + var oldNameParam = cmd.GetProperty("parameters").EnumerateArray() + .FirstOrDefault(p => p.GetProperty("name").GetString() == "old-name"); + oldNameParam.TryGetProperty("deprecated", out var dep).Should().BeTrue(); + dep.GetProperty("message").GetString().Should().Contain("--name"); + } + + [Fact] + public void Schema_command_with_intent_emits_intent_object() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-intent-destructive"); + cmd.ValueKind.Should().Be(JsonValueKind.Object); + var intent = cmd.GetProperty("intent"); + intent.GetProperty("destructive").GetBoolean().Should().BeTrue(); + intent.GetProperty("requiresConfirmation").GetBoolean().Should().BeTrue(); + intent.GetProperty("requiresAuth").GetBoolean().Should().BeTrue(); + intent.GetProperty("scope").GetString().Should().Be("global"); + intent.TryGetProperty("idempotent", out _).Should().BeFalse(); + } + + [Fact] + public void Schema_confirmationSkip_parameter_has_correct_role() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-intent-destructive"); + var yesParam = cmd.GetProperty("parameters").EnumerateArray() + .FirstOrDefault(p => p.GetProperty("name").GetString() == "yes"); + yesParam.GetProperty("role").GetString().Should().Be("confirmationSkip"); + } + + [Fact] + public void Schema_dryRun_parameter_has_correct_role() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-intent-read"); + var dryRunParam = cmd.GetProperty("parameters").EnumerateArray() + .FirstOrDefault(p => p.GetProperty("name").GetString() == "dry-run"); + dryRunParam.GetProperty("role").GetString().Should().Be("dryRun"); + } + + [Fact] + public void Schema_command_with_output_formats_emits_output_object() + { + var result = CliHostRunner.Run("__schema"); + result.ExitCode.Should().Be(0); + using var doc = JsonDocument.Parse(CliHostRunner.StdoutText(result)); + var cmd = doc.RootElement.GetProperty("commands").EnumerateArray() + .FirstOrDefault(c => c.GetProperty("name").GetString() == "schema-output-formats"); + cmd.ValueKind.Should().Be(JsonValueKind.Object); + var output = cmd.GetProperty("output"); + output.GetProperty("formatFlag").GetString().Should().Be("--format"); + var formats = output.GetProperty("formats").EnumerateArray().Select(f => f.GetString()).ToArray(); + formats.Should().BeEquivalentTo(new[] { "json", "table", "csv" }); + } } diff --git a/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs b/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs index 4f3fee5..b20b8d3 100644 --- a/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Help/RootAndNamespaceHelpTests.cs @@ -93,8 +93,17 @@ handler parameter (-m still parses). renamed-cmd schema-default-value Schema test: command with a default value on --level. + schema-deprecated-param A command with a deprecated parameter + via [AsParameters]. + schema-deprecated-simple A command that is deprecated without a + message. + schema-deprecated-with-message A command that is deprecated with a + migration message. schema-hidden-param Schema test: command with a hidden parameter. + schema-intent-destructive Deletes all resources permanently. + schema-intent-read Lists resources safely. + schema-output-formats Reports status in multiple formats. schema-separator-list Schema test: command with a separator-based collection on --ids. severity-cmd diff --git a/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs b/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs index 19e3bf9..2f07d95 100644 --- a/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs +++ b/tests/Nullean.Argh.IntegrationTests/Help/RootHelpFullTextTests.cs @@ -94,8 +94,17 @@ handler parameter (-m still parses). renamed-cmd schema-default-value Schema test: command with a default value on --level. + schema-deprecated-param A command with a deprecated parameter + via [AsParameters]. + schema-deprecated-simple A command that is deprecated without a + message. + schema-deprecated-with-message A command that is deprecated with a + migration message. schema-hidden-param Schema test: command with a hidden parameter. + schema-intent-destructive Deletes all resources permanently. + schema-intent-read Lists resources safely. + schema-output-formats Reports status in multiple formats. schema-separator-list Schema test: command with a separator-based collection on --ids. severity-cmd diff --git a/tests/Nullean.Argh.Tests/CliRegistrationModule.cs b/tests/Nullean.Argh.Tests/CliRegistrationModule.cs index e6b74a4..daded34 100644 --- a/tests/Nullean.Argh.Tests/CliRegistrationModule.cs +++ b/tests/Nullean.Argh.Tests/CliRegistrationModule.cs @@ -76,6 +76,12 @@ internal static void RegisterCommands() app.Map("schema-separator-list", SchemaSpecificHandlers.SchemaSeparatorList); app.Map("schema-hidden-param", SchemaSpecificHandlers.SchemaHiddenParam); app.Map(); + app.Map("schema-deprecated-simple", SchemaDeprecatedHandlers.SchemaDeprecatedSimple); + app.Map("schema-deprecated-with-message", SchemaDeprecatedHandlers.SchemaDeprecatedWithMessage); + app.Map("schema-deprecated-param", SchemaDeprecatedHandlers.SchemaDeprecatedParam); + app.Map("schema-intent-destructive", SchemaIntentHandlers.SchemaIntentDestructive); + app.Map("schema-intent-read", SchemaIntentHandlers.SchemaIntentRead); + app.Map("schema-output-formats", SchemaOutputHandlers.SchemaOutputFormats); app.MapNamespace("storage", g => { g.UseNamespaceOptions(); diff --git a/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs b/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs index 21bf256..439007f 100644 --- a/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs +++ b/tests/Nullean.Argh.Tests/Fixtures/SchemaSpecificHandlers.cs @@ -1,4 +1,5 @@ using Nullean.Argh; +using Nullean.Argh.Documentation; namespace Nullean.Argh.Tests.Fixtures; @@ -37,3 +38,69 @@ internal sealed class SchemaHiddenCommands [NoOptionsInjection] public static void HiddenCmd() => Console.Out.WriteLine("hidden"); } + +/// Schema test: deprecated commands and parameters. +internal static partial class SchemaDeprecatedHandlers +{ + /// A command that is deprecated without a message. + [Obsolete] + [NoOptionsInjection] + public static void SchemaDeprecatedSimple() => Console.Out.WriteLine("deprecated-simple"); + + /// A command that is deprecated with a migration message. + [Obsolete("Use schema-deprecated-replacement instead.")] + [NoOptionsInjection] + public static void SchemaDeprecatedWithMessage() => Console.Out.WriteLine("deprecated-message"); + +} + +/// Schema test: a DTO with a deprecated property, used via [AsParameters]. +internal sealed class SchemaDeprecatedParamArgs +{ + /// The new flag to use. + public string Name { get; init; } = ""; + + /// Deprecated alias for name. + [Obsolete("Use --name instead.")] + public string? OldName { get; init; } +} + +internal static partial class SchemaDeprecatedHandlers +{ + /// A command with a deprecated parameter via [AsParameters]. + [NoOptionsInjection] + public static void SchemaDeprecatedParam([AsParameters] SchemaDeprecatedParamArgs args) => + Console.Out.WriteLine($"name:{args.Name ?? args.OldName}"); +} + +/// Schema test: intent annotation on a destructive command. +internal static class SchemaIntentHandlers +{ + /// Deletes all resources permanently. + [CommandIntent(Intent.Destructive | Intent.RequiresConfirmation)] + [MutationScope(MutationScope.Global)] + [RequiresAuth] + [NoOptionsInjection] + public static void SchemaIntentDestructive( + [ConfirmationSkip] bool yes = false) => + Console.Out.WriteLine("deleted"); + + /// Lists resources safely. + [CommandIntent(Intent.Idempotent)] + [NoOptionsInjection] + public static void SchemaIntentRead( + [DryRun] bool dryRun = false) => + Console.Out.WriteLine("list"); +} + +/// Enum for output format selection. +internal enum OutputFormat { Json, Table, Csv } + +/// Schema test: output formats on a command — enum parameter. +internal static class SchemaOutputHandlers +{ + /// Reports status in multiple formats. + [NoOptionsInjection] + public static void SchemaOutputFormats([CommandOutput] OutputFormat? format = null) => + Console.Out.WriteLine($"format:{format}"); +}