From e242698605d57efa78cd9f47f0594fadcdc99345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Fri, 16 Oct 2026 14:14:03 +0200 Subject: [PATCH 01/15] Centralize shell output and preserve machine-mode contracts Extract the output policy and command migrations from #239 without MCP stdio. Preserve diagnostics and required instructions in quiet mode, report command errors consistently, and require bounded machine-mode watch results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + .../DirectConsoleOutputAnalyzer.cs | 107 ++++++++ CosmosDBShell.Analyzer/GlobalSuppressions.cs | 1 + .../CommandTests/BucketCommandTests.cs | 6 +- .../CommandTests/ThemeCommandDispatchTests.cs | 40 +-- .../CommandTests/ThemeCommandTests.cs | 10 +- .../CommandTests/WatchCommandTests.cs | 128 +++++++++ .../Integration/AdditionalCommandsTests.cs | 6 +- .../Integration/ShellProcessTests.cs | 187 +++++++++++++- .../Parser/StatementPositionalErrorTests.cs | 18 +- .../UtilTest/ShellOutputTests.cs | 152 +++++++++++ .../BatchCommand.cs | 12 +- .../BatchExecutor.cs | 4 +- .../BucketCommand.cs | 36 ++- .../CanICommand.cs | 6 +- .../CdCommand.cs | 8 +- .../ClsCommand.cs | 2 +- .../ConflictCommand.cs | 2 +- .../ConnectCommand.cs | 20 +- .../DirCommand.cs | 10 +- .../DisconnectCommand.cs | 4 +- .../DoctorCommand.cs | 8 +- .../EditCommand.cs | 19 +- .../ExportCommand.cs | 2 +- .../FtabCommand.cs | 2 +- .../HelpCommand.cs | 160 ++++++------ .../ImportCommand.cs | 72 +++--- .../IndexCommand.cs | 2 +- .../InfoCommand.cs | 120 ++++----- .../ListCommand.cs | 18 +- .../MakeContainerCommand.cs | 2 +- .../MakeDbCommand.cs | 2 +- .../MakeItemCommand.cs | 138 +++++----- .../PatchCommand.cs | 2 +- .../PwdCommand.cs | 2 +- .../QueryCommand.cs | 30 ++- .../ReplaceCommand.cs | 31 ++- .../RmCommand.cs | 7 +- .../RmContainerCommand.cs | 4 +- .../RmDbCommand.cs | 4 +- .../SprocCommand.cs | 34 +-- .../ThemeCommand.cs | 243 ++++++++++-------- .../ThroughputCommand.cs | 12 +- .../TriggerCommand.cs | 32 +-- .../TtlCommand.cs | 4 +- .../UdfCommand.cs | 32 +-- .../WatchCommand.cs | 13 +- .../WhoamiCommand.cs | 6 +- .../CosmosCompletionRenderer.cs | 1 + .../OutputPolicy.cs | 14 + .../ShellInterpreter.cs | 182 ++++++------- .../ShellOutput.cs | 218 ++++++++++++++++ .../ClearScreenCommand.cs | 4 +- .../ReverseSearchHistoryCommand.cs | 2 + CosmosDBShell/GlobalSuppressions.cs | 5 - CosmosDBShell/Program.cs | 50 ++-- CosmosDBShell/lang/en.ftl | 5 +- docs/commands.md | 4 + docs/navigation.md | 12 +- l10n/CosmosDBShell.json | 5 +- 60 files changed, 1543 insertions(+), 722 deletions(-) create mode 100644 CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs create mode 100644 CosmosDBShell.Tests/UtilTest/ShellOutputTests.cs create mode 100644 CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d0f3e13d..b9c1d5f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Breaking changes - `print ` now parses the key with the same typed syntax as `patch`, so `print item-1 680` reads numeric key `680` instead of string `"680"`. To address a string key that looks like a number, boolean, null, or JSON array, quote it as a JSON string: `print item-1 '"680"'`. ([#244](https://github.com/Azure/CosmosDBShell/issues/244)) +- Machine-mode `watch` now requires a positive `--max` and returns collected changes as a bounded final result. Interactive watches can still follow until Ctrl+C. ### New features @@ -12,10 +13,12 @@ - `print` now reads items with typed scalar and complete hierarchical partition keys, for example `print customer-1001 '[1001,"customer-1001"]'`. Invalid keys are distinguished from missing items. Shared argument parsing rejects object/nested-array components and non-finite numbers instead of coercing them. ([#244](https://github.com/Azure/CosmosDBShell/issues/244)) - Avoid accessing an expired HTTP request during MCP session teardown, preventing disposal errors while releasing location subscriptions and legacy SSE admission state. ([#249](https://github.com/Azure/CosmosDBShell/issues/249)) +- Report `theme`, `help`, and `edit` failures once, including a structured error on stderr in machine mode instead of an unexplained failure exit code. ### Improvements - `import` now streams documents with up to 16 concurrent writes by default. Use `--concurrency` to tune the limit or `--concurrency=1` for sequential, file-order writes. On a failure, imports stop scheduling new writes and wait for in-flight writes; `--continue-on-error` continues after per-item write failures. +- Apply a consistent output policy: `--quiet` suppresses informational messages, progress, banners, and command echoes while preserving results, warnings, errors, and required authentication or confirmation instructions. Machine-mode stdout carries only command results; diagnostics use stderr without ANSI styling. ## 1.1.280 — 2026-10-09 diff --git a/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs b/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs new file mode 100644 index 00000000..d0afeb36 --- /dev/null +++ b/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; + +namespace CosmosShell.Analyzer +{ + /// + /// Requires user-facing console output to go through ShellOutput, which applies the + /// quiet and machine-mode presentation policies. Direct writes bypass those + /// policies and can corrupt structured stdout. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public class DirectConsoleOutputAnalyzer : DiagnosticAnalyzer + { + public const string DiagnosticId = "CZ0003"; + + private const string Category = "Usage"; + private const string AllowedType = "Azure.Data.Cosmos.Shell.Core.ShellOutput"; + + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + DiagnosticId, + "Direct console output", + "'{0}' bypasses the shell output policy; write through ShellOutput instead", + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "User-facing output must go through ShellOutput so --quiet and machine mode are honored."); + + public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } } + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.InvocationExpression); + } + + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + if (!(context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol is IMethodSymbol method)) + { + return; + } + + if (!IsDirectOutput(context.SemanticModel, invocation, method, context)) + { + return; + } + + var containingType = context.ContainingSymbol?.ContainingType; + while (containingType != null) + { + if (containingType.ToDisplayString() == AllowedType) + { + return; + } + + containingType = containingType.ContainingType; + } + + context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.GetLocation(), method.ContainingType.Name + "." + method.Name)); + } + + private static bool IsDirectOutput(SemanticModel model, InvocationExpressionSyntax invocation, IMethodSymbol method, SyntaxNodeAnalysisContext context) + { + var type = method.ContainingType?.ToDisplayString(); + + // Console.Write / Console.WriteLine. + if (method.IsStatic && type == "System.Console" && method.Name.StartsWith("Write")) + { + return true; + } + + // Every static AnsiConsole call except Create renders to the global console. + if (method.IsStatic && type == "Spectre.Console.AnsiConsole" && method.Name != "Create") + { + return true; + } + + // Console.Out.Write*, Console.Error.Write*, and AnsiConsole.Console.. + if (invocation.Expression is MemberAccessExpressionSyntax access + && model.GetSymbolInfo(access.Expression, context.CancellationToken).Symbol is IPropertySymbol receiver + && receiver.IsStatic) + { + var owner = receiver.ContainingType?.ToDisplayString(); + if (owner == "System.Console" && (receiver.Name == "Out" || receiver.Name == "Error") && method.Name.StartsWith("Write")) + { + return true; + } + + if (owner == "Spectre.Console.AnsiConsole" && receiver.Name == "Console") + { + return true; + } + } + + return false; + } + } +} diff --git a/CosmosDBShell.Analyzer/GlobalSuppressions.cs b/CosmosDBShell.Analyzer/GlobalSuppressions.cs index f6ec9e97..db9f4d45 100644 --- a/CosmosDBShell.Analyzer/GlobalSuppressions.cs +++ b/CosmosDBShell.Analyzer/GlobalSuppressions.cs @@ -11,3 +11,4 @@ [assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "", Scope = "member", Target = "~F:CosmosShell.Analyzer.EmptyCatchClauseAnalyzer.Rule")] [assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "", Scope = "member", Target = "~F:CosmosShell.Analyzer.WriteLineAnalyzer.Rule")] +[assembly: SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "", Scope = "member", Target = "~F:CosmosShell.Analyzer.DirectConsoleOutputAnalyzer.Rule")] diff --git a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs index 97f068ed..d32f97b0 100644 --- a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs @@ -99,16 +99,16 @@ public async Task ExecuteAsync_InContainer_NoArgs_ShowsCurrent() } [Fact] - public async Task ExecuteAsync_InDatabase_InvalidValue_ReturnsEmptyState() + public async Task ExecuteAsync_InDatabase_InvalidValue_ThrowsCommandException() { var client = CreateTestClient(); using var shell = ShellInterpreter.CreateInstance(); shell.State = new DatabaseState("TestDatabase", client); var command = new BucketCommand { Action = "99" }; - var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None); + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None)); - Assert.False(state.IsError); Assert.Null(client.ClientOptions.ThroughputBucket); } diff --git a/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs b/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs index 4c9bd737..a35614f9 100644 --- a/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs @@ -123,9 +123,7 @@ public async Task Show_KnownTheme_PreviewsWithoutChangingActive(string name) [Fact] public async Task Show_UnknownTheme_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "show", Name = "does-not-exist-xyz" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "show", Name = "does-not-exist-xyz" })); } [Fact] @@ -167,25 +165,19 @@ public async Task Set_AliasOfUse_AppliesTheme() [Fact] public async Task Use_MissingName_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "use" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "use" })); } [Fact] public async Task Use_UnknownName_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "use", Name = "nope-xyz" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "use", Name = "nope-xyz" })); } [Fact] public async Task Load_MissingPath_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "load" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "load" })); } [Fact] @@ -214,9 +206,7 @@ public async Task Load_NonExistentFile_ReturnsError() { var path = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.toml"); - var state = await RunAsync(new ThemeCommand { Action = "load", Name = path }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "load", Name = path })); } [Fact] @@ -225,9 +215,7 @@ public async Task Load_InvalidToml_ReturnsError() var path = WriteTempToml($"bad-{Guid.NewGuid():N}", "this is = = not valid toml [[["); try { - var state = await RunAsync(new ThemeCommand { Action = "load", Name = path }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "load", Name = path })); } finally { @@ -238,17 +226,13 @@ public async Task Load_InvalidToml_ReturnsError() [Fact] public async Task Save_MissingName_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "save" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "save" })); } [Fact] public async Task Save_InvalidName_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "save", Name = "../escape" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "save", Name = "../escape" })); } [Fact] @@ -280,9 +264,7 @@ public async Task Save_ExistingFileWithoutForce_ReturnsError() File.WriteAllText(path, "name = \"existing\""); try { - var state = await RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path })); } finally { @@ -321,9 +303,7 @@ public async Task Reload_RescansUserThemesDirectory() [Fact] public async Task UnknownAction_ReturnsError() { - var state = await RunAsync(new ThemeCommand { Action = "frobnicate" }); - - Assert.IsType(state); + await Assert.ThrowsAsync(() => RunAsync(new ThemeCommand { Action = "frobnicate" })); } private static Task RunAsync(ThemeCommand command) => diff --git a/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs index ec163e6f..a65e3d4a 100644 --- a/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs @@ -81,10 +81,9 @@ public async Task Validate_ScansDirectoryAndReportsCounts() Name = dir.Path, }; - var state = await command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None); - var error = Assert.IsType(state); + var error = await Assert.ThrowsAsync(() => command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None)); - Assert.Contains("1 of 2", error.Exception.Message); + Assert.Contains("1 of 2", error.Message); } [Fact] @@ -109,9 +108,8 @@ public async Task Validate_StrictTreatsWarningsAsErrors() Strict = true, }; - var state = await command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None); - var error = Assert.IsType(state); - Assert.Contains("strict mode", error.Exception.Message); + var error = await Assert.ThrowsAsync(() => command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), "", CancellationToken.None)); + Assert.Contains("strict mode", error.Message); } finally { diff --git a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs index d890fa23..a02dff92 100644 --- a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs @@ -4,9 +4,14 @@ namespace CosmosShell.Tests.CommandTests; +using System.Net; +using System.Text; +using System.Text.Json; using Azure.Data.Cosmos.Shell.Commands; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.States; +using Microsoft.Azure.Cosmos; +using NSubstitute; /// /// Unit tests for . Covers the pure helpers that @@ -14,8 +19,76 @@ namespace CosmosShell.Tests.CommandTests; /// plus the option-validation and not-connected guards, all of which can be /// exercised without a live Cosmos DB connection. /// +[Collection(ConsoleOutputTestCollection.Name)] public class WatchCommandTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ExecuteAsync_BoundedMachineWatch_ReturnsExactlyMaxChangesOnce(bool quiet) + { + var client = Substitute.For(); + var database = Substitute.For(); + var container = Substitute.For(); + client.GetDatabase("db").Returns(database); + database.GetContainer("items").Returns(container); + container.Id.Returns("items"); + var databaseResponse = Substitute.For(); + databaseResponse.StatusCode.Returns(HttpStatusCode.OK); + database.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(databaseResponse)); + var containerResponse = Substitute.For(); + containerResponse.StatusCode.Returns(HttpStatusCode.OK); + container.ReadContainerAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(containerResponse)); + + using var firstPage = new ResponseMessage(HttpStatusCode.OK) + { + Content = new MemoryStream(Encoding.UTF8.GetBytes("{\"Documents\":[{\"id\":\"1\"},{\"id\":\"2\"}]}")), + }; + using var secondPage = new ResponseMessage(HttpStatusCode.OK) + { + Content = new MemoryStream(Encoding.UTF8.GetBytes("{\"Documents\":[{\"id\":\"3\"},{\"id\":\"4\"}]}")), + }; + var iterator = Substitute.For(); + iterator.ReadNextAsync(Arg.Any()) + .Returns(Task.FromResult(firstPage), Task.FromResult(secondPage)); + container.GetChangeFeedStreamIterator( + Arg.Any(), ChangeFeedMode.Incremental, Arg.Any()) + .Returns(iterator); + + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { Quiet = quiet, Output = "json" }; + shell.State = new ContainerState("items", "db", client); + var command = new WatchCommand { Max = 3 }; + var savedOut = Console.Out; + var savedError = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var state = await command.ExecuteAsync(shell, new CommandState(), "watch --max=3", TestContext.Current.CancellationToken); + Assert.Empty(stdout.ToString()); + Assert.Empty(stderr.ToString()); + + shell.PrintState(state); + using var json = JsonDocument.Parse(stdout.ToString()); + Assert.Equal("item", json.RootElement.GetProperty("type").GetString()); + Assert.Equal( + ["1", "2", "3"], + json.RootElement.GetProperty("values").EnumerateArray().Select(item => item.GetProperty("id").GetString())); + Assert.Empty(stderr.ToString()); + await iterator.Received(2).ReadNextAsync(Arg.Any()); + } + finally + { + Console.SetOut(savedOut); + Console.SetError(savedError); + } + } + [Fact] public void ParseChangeFeedDocuments_ReturnsAllDocuments() { @@ -118,4 +191,59 @@ public async Task ExecuteAsync_Disconnected_ThrowsNotConnected() await Assert.ThrowsAsync( () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); } + + [Theory] + [InlineData(true, null, null)] + [InlineData(true, null, 0)] + [InlineData(true, null, -1)] + [InlineData(false, "json", null)] + [InlineData(false, "json", 0)] + [InlineData(false, "csv", -1)] + public async Task ExecuteAsync_UnboundedMachineWatch_RejectsBeforeConnection(bool quiet, string? output, int? max) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { Quiet = quiet, Output = output }; + shell.State = new DisconnectedState(); + var command = new WatchCommand { Max = max }; + + var error = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); + + Assert.Contains("positive --max", error.Message); + } + + [Theory] + [InlineData(null)] + [InlineData("changes.json")] + public async Task ExecuteAsync_UnboundedExecuteAndQuitWatch_RejectsIncludingRedirection(string? redirectedPath) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { ExecuteAndQuit = "watch" }; + shell.StdOutRedirect = redirectedPath; + shell.State = new DisconnectedState(); + var command = new WatchCommand(); + + var error = await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); + + Assert.Contains("positive --max", error.Message); + } + + [Theory] + [InlineData(true, null, 1)] + [InlineData(false, "json", 100)] + [InlineData(false, "csv", 1)] + [InlineData(false, "user", null)] + [InlineData(false, "table", 0)] + [InlineData(false, null, -1)] + public async Task ExecuteAsync_BoundedMachineOrInteractiveWatch_PreservesConnectionGuard(bool quiet, string? output, int? max) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { Quiet = quiet, Output = output }; + shell.State = new DisconnectedState(); + var command = new WatchCommand { Max = max }; + + await Assert.ThrowsAsync( + () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); + } } diff --git a/CosmosDBShell.Tests/Integration/AdditionalCommandsTests.cs b/CosmosDBShell.Tests/Integration/AdditionalCommandsTests.cs index 24097fe0..63d37829 100644 --- a/CosmosDBShell.Tests/Integration/AdditionalCommandsTests.cs +++ b/CosmosDBShell.Tests/Integration/AdditionalCommandsTests.cs @@ -71,12 +71,10 @@ public async Task Bucket_ShowsCurrent_SetsAndResets() } [Fact] - public async Task Bucket_InvalidValue_DoesNotErrorButDoesNotSet() + public async Task Bucket_InvalidValue_ReturnsError() { - // CheckBucket prints a message for out-of-range values but returns an empty state - // rather than throwing. We still ensure it does not crash the shell. var state = await ExecuteAsync("bucket 99"); - Assert.False(state.IsError, IntegrationTestBase.FormatError(state)); + Assert.True(state.IsError); } [Fact] diff --git a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs index 4f3e65a7..df55da48 100644 --- a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs +++ b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs @@ -24,6 +24,187 @@ public class ShellProcessTests { private static readonly Regex AnsiEscape = new("\x1b\\[[0-9;?]*[ -/]*[@-~]", RegexOptions.Compiled); + [Theory] + [InlineData("theme use MACHINE_NOPE", "MACHINE_NOPE")] + [InlineData("help MACHINE_MISSING", "MACHINE_MISSING")] + [InlineData("edit machine.txt", "interactive terminal")] + public async Task MachineMode_CommandFailure_ReportsSingleStructuredError(string command, string expected) + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ExitCode); + Assert.Empty(result.StdOut); + var line = Assert.Single(result.StdErr.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + using var error = JsonDocument.Parse(line); + Assert.Equal("error", error.RootElement.GetProperty("status").GetString()); + Assert.Contains(expected, error.RootElement.GetProperty("error").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Quiet_PreservesResultsAndStartupWarningsWithoutAnsi() + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--quiet", "--theme", "QUIET_UNKNOWN_THEME", "-c", "echo QUIET_RESULT"], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + Assert.Equal("QUIET_RESULT", result.StdOut.Trim()); + Assert.Contains("QUIET_UNKNOWN_THEME", result.StdErr); + Assert.DoesNotContain("Cosmos Shell version:", result.StdOut); + Assert.DoesNotContain("\u001b", result.RawStdOut + result.RawStdErr, StringComparison.Ordinal); + } + + [Theory] + [InlineData("save", "saved")] + [InlineData("load", "loaded")] + [InlineData("validate", "valid")] + public async Task Quiet_SuppressesCommandInformationButPreservesStructuredResult(string action, string resultProperty) + { + var file = Path.Join(Path.GetTempPath(), $"cosmosshell-output-{Guid.NewGuid():N}.toml"); + try + { + if (action != "save") + { + await File.WriteAllTextAsync(file, "name = \"OUTPUT_TEST\"\n[colors]\nliteral = \"purple\"\n", TestContext.Current.CancellationToken); + } + + var command = action == "save" + ? $"theme save OUTPUT_TEST '{file}' --force" + : $"theme {action} '{file}'"; + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--quiet", "-c", command], + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(0, result.ExitCode); + using var json = JsonDocument.Parse(result.StdOut); + Assert.True(json.RootElement.GetProperty(resultProperty).GetBoolean()); + Assert.Empty(result.StdErr); + } + finally + { + File.Delete(file); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ThemeValidation_Success_PreservesWarningsOnStderr(bool quiet, bool directory) + { + var path = Path.Join(Path.GetTempPath(), $"cosmosshell-theme-warnings-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + try + { + var file = Path.Join(path, "warning.toml"); + await File.WriteAllTextAsync( + file, + "name = \"WARNING_TEST\"\n[colors]\nunknown_warning_slot = \"red\"\n", + TestContext.Current.CancellationToken); + var target = directory ? path : file; + var command = $"theme validate '{target}'"; + var result = await RunShellAsync( + stdinScript: null, + extraArgs: quiet ? ["--quiet", "-c", command] : ["-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + using var json = JsonDocument.Parse(result.StdOut); + var validation = directory ? json.RootElement.GetProperty("values")[0] : json.RootElement; + Assert.True(validation.GetProperty("valid").GetBoolean()); + Assert.NotEqual(0, validation.GetProperty("warnings").GetArrayLength()); + Assert.Contains("unknown_warning_slot", result.StdErr); + Assert.DoesNotContain("\u001b", result.RawStdOut + result.RawStdErr, StringComparison.Ordinal); + } + finally + { + File.Delete(Path.Join(path, "warning.toml")); + Directory.Delete(path); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ThemeValidation_FailedDirectoryScan_ReportsOnlyStructuredError(bool quiet, bool strict) + { + var path = Path.Join(Path.GetTempPath(), $"cosmosshell-theme-errors-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + try + { + await File.WriteAllTextAsync( + Path.Join(path, "a-warning.toml"), + "name = \"WARNING_TEST\"\n[colors]\nunknown_warning_slot = \"red\"\n", + TestContext.Current.CancellationToken); + if (!strict) + { + await File.WriteAllTextAsync( + Path.Join(path, "z-broken.toml"), + "name = \"BROKEN_TEST\"\n[colors]\nliteral = \"not_a_color\"\n", + TestContext.Current.CancellationToken); + } + + var command = $"theme validate '{path}'" + (strict ? " --strict" : string.Empty); + var result = await RunShellAsync( + stdinScript: null, + extraArgs: quiet ? ["--quiet", "-c", command] : ["-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ExitCode); + Assert.Empty(result.StdOut); + var line = Assert.Single(result.StdErr.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + using var error = JsonDocument.Parse(line); + Assert.Equal("error", error.RootElement.GetProperty("status").GetString()); + Assert.DoesNotContain("unknown_warning_slot", result.StdErr); + } + finally + { + File.Delete(Path.Join(path, "a-warning.toml")); + File.Delete(Path.Join(path, "z-broken.toml")); + Directory.Delete(path); + } + } + + [Theory] + [InlineData("--help", "USAGE:")] + [InlineData("--version", "Cosmos")] + public async Task Quiet_ExplicitStartupResult_RemainsVisible(string option, string expected) + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--quiet", option], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + Assert.Contains(expected, result.StdOut); + Assert.Empty(result.StdErr); + } + + [Theory] + [InlineData("help theme")] + [InlineData("theme list")] + [InlineData("version")] + public async Task MachineMode_HumanViews_ReturnOnlyStructuredResult(string command) + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["--output", "json", "-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + using var json = JsonDocument.Parse(result.StdOut); + Assert.NotEqual(JsonValueKind.Undefined, json.RootElement.ValueKind); + Assert.Empty(result.StdErr); + Assert.DoesNotContain("\u001b", result.RawStdOut, StringComparison.Ordinal); + } + [Theory] [InlineData("value", "identity", "1 argument, got 0")] [InlineData("value", "identity 1 2", "1 argument, got 2")] @@ -618,7 +799,9 @@ private static async Task RunShellAsync( return new ShellProcessResult( process.ExitCode, Strip(stdOut.ToString()), - Strip(stdErr.ToString())); + Strip(stdErr.ToString()), + stdOut.ToString(), + stdErr.ToString()); } finally { @@ -654,5 +837,5 @@ private static string Strip(string text) return AnsiEscape.Replace(text, string.Empty); } - private sealed record ShellProcessResult(int ExitCode, string StdOut, string StdErr); + private sealed record ShellProcessResult(int ExitCode, string StdOut, string StdErr, string RawStdOut, string RawStdErr); } diff --git a/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs b/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs index b5dcf161..24d93f89 100644 --- a/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs +++ b/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs @@ -7,6 +7,7 @@ namespace CosmosShell.Tests.Parser; using System.Threading; using System.Threading.Tasks; +using Azure.Data.Cosmos.Shell.Commands; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.Parser; @@ -35,18 +36,20 @@ public async Task ThrownExpressionError_UsesCommandLocation(string source) } [Theory] - [InlineData("$value = (help totallyunknowncmd999)")] - [InlineData("if (help totallyunknowncmd999) {}")] - [InlineData("for $item in (help totallyunknowncmd999) {}")] + [InlineData("$value = (returned-error-test)")] + [InlineData("if (returned-error-test) {}")] + [InlineData("for $item in (returned-error-test) {}")] public async Task ReturnedExpressionError_UsesCommandLocation(string source) { + Assert.True(CommandFactory.TryCreateFactory(typeof(ReturnedErrorTestCommand), out var factory)); + Shell.App.Commands["returned-error-test"] = factory; Shell.CurrentScriptFileName = "expression.csh"; Shell.CurrentScriptContent = source; var statement = new StatementParser(source).ParseStatement()!; var exception = await Assert.ThrowsAsync(() => statement.RunAsync(Shell, new(), TestContext.Current.CancellationToken)); var frame = PositionalException.GetSourceTrace(exception)[0]; Assert.Equal("expression.csh", frame.FileName); - Assert.Equal(source.IndexOf("help", StringComparison.Ordinal) + 1, frame.Column); + Assert.Equal(source.IndexOf("returned-error-test", StringComparison.Ordinal) + 1, frame.Column); var failure = Assert.IsType(frame.InnerException); Assert.IsType(failure.State); } @@ -66,6 +69,13 @@ public async Task InteractiveFunction_RetainsScriptModeWithoutBorrowingCallerOff Assert.Equal("\nprobe", Shell.CurrentScriptContent); } + [CosmosCommand("returned-error-test")] + internal sealed class ReturnedErrorTestCommand : CosmosCommand + { + public override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + => Task.FromResult(new ErrorCommandState(new CommandException("returned-error-test", "failed"))); + } + private sealed class ScriptContextProbe : Statement { public override int Start => 100; diff --git a/CosmosDBShell.Tests/UtilTest/ShellOutputTests.cs b/CosmosDBShell.Tests/UtilTest/ShellOutputTests.cs new file mode 100644 index 00000000..4978a66b --- /dev/null +++ b/CosmosDBShell.Tests/UtilTest/ShellOutputTests.cs @@ -0,0 +1,152 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.UtilTest; + +using Azure.Data.Cosmos.Shell.Core; +using Spectre.Console; + +public class ShellOutputTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public void Presentation_UsesCategoryPolicyForTextMarkupAndRenderables(bool quiet, bool machineMode) + { + foreach (var kind in Enum.GetValues()) + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var output = CreateOutput(quiet, machineMode, stdout, stderr); + var marker = kind.ToString(); + + output.Write(kind, marker + "_text"); + output.WriteLine(kind, marker + "_line"); + output.Markup(kind, $"[red]{marker}_markup[/]"); + output.MarkupLine(kind, $"[red]{marker}_markupLine[/]"); + output.Render(kind, new Text(marker + "_renderable")); + output.RenderLine(kind, marker + "_renderLine"); + output.WriteException(kind, new InvalidOperationException(marker + "_exception")); + + var suppressed = ((quiet || machineMode) && kind is ShellMessageKind.Information or ShellMessageKind.Progress) + || (machineMode && kind == ShellMessageKind.Result); + if (suppressed) + { + Assert.Empty(stdout.ToString()); + Assert.Empty(stderr.ToString()); + continue; + } + + var useStderr = machineMode && kind is ShellMessageKind.Warning or ShellMessageKind.Error or ShellMessageKind.RequiredInstruction; + var visible = (useStderr ? stderr : stdout).ToString(); + Assert.Empty((useStderr ? stdout : stderr).ToString()); + foreach (var suffix in new[] { "_text", "_line", "_markup", "_markupLine", "_renderable", "_renderLine", "_exception" }) + { + Assert.Contains(marker + suffix, visible); + } + + if (machineMode) + { + Assert.DoesNotContain("\u001b", visible, StringComparison.Ordinal); + } + } + } + + [Fact] + public void StructuredMode_RoutesWarningsErrorsAndAuthenticationToStderr() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var output = CreateOutput(false, true, stdout, stderr); + output.MarkupLine(ShellMessageKind.Warning, "[yellow]warning[/]"); + output.WriteLine(ShellMessageKind.Error, "error"); + output.WriteLine(ShellMessageKind.RequiredInstruction, "login"); + output.WriteLine(ShellMessageKind.Result, "eager table"); + output.Render(ShellMessageKind.Result, new Text("eager renderable")); + output.WriteResult("{\"result\":true}"); + Assert.Equal("{\"result\":true}" + Environment.NewLine, stdout.ToString()); + Assert.DoesNotContain("eager", stderr.ToString(), StringComparison.Ordinal); + Assert.Contains("warning", stderr.ToString()); + Assert.Contains("error", stderr.ToString()); + Assert.Contains("login", stderr.ToString()); + Assert.DoesNotContain("\u001b", stderr.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Modes_AreEvaluatedAtWriteTime() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var quiet = false; + var machineMode = false; + var output = new ShellOutput(() => quiet, () => machineMode, () => stdout, () => stderr); + output.WriteLine(ShellMessageKind.Information, "before"); + quiet = true; + machineMode = true; + output.WriteLine(ShellMessageKind.Information, "hidden"); + output.WriteLine(ShellMessageKind.Result, "command-level result hidden in machine mode"); + output.WriteResult("result"); + output.WriteLine(ShellMessageKind.RequiredInstruction, "device code"); + Assert.Equal("before" + Environment.NewLine + "result" + Environment.NewLine, stdout.ToString()); + Assert.Equal("device code" + Environment.NewLine, stderr.ToString()); + } + + [Theory] + [InlineData(false, true)] + [InlineData(true, false)] + public void ClearScreen_OnlyTouchesInteractiveTerminal(bool machineMode, bool cleared) + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var terminal = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(stdout), + }); + var output = new ShellOutput(() => false, () => machineMode, () => stdout, () => stderr, () => terminal); + output.ClearScreen(); + Assert.Equal(cleared, stdout.ToString().Length > 0); + Assert.Empty(stderr.ToString()); + } + + [Fact] + public void Text_PreservesBracesAndExplicitFormatting() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var output = CreateOutput(false, false, stdout, stderr); + output.WriteLine(ShellMessageKind.Result, "{\"value\":1}"); + output.Write(ShellMessageKind.Result, "{0}={1}", "value", 2); + Assert.Equal("{\"value\":1}" + Environment.NewLine + "value=2", stdout.ToString()); + } + + [Fact] + public void Markup_PreservesLiteralBracesWithoutFormattingArguments() + { + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var output = CreateOutput(false, false, stdout, stderr); + output.Markup(ShellMessageKind.Result, "[red]{\"value\":1}[/]"); + output.MarkupLine(ShellMessageKind.Result, "[red]{\"value\":2}[/]"); + output.MarkupLine(ShellMessageKind.Result, "[red]{0}[/]", "formatted"); + Assert.Equal("{\"value\":1}{\"value\":2}" + Environment.NewLine + "formatted" + Environment.NewLine, stdout.ToString()); + } + + private static ShellOutput CreateOutput(bool quiet, bool machineMode, StringWriter stdout, StringWriter stderr) + => new( + () => quiet, + () => machineMode, + () => stdout, + () => stderr, + () => AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(stdout), + })); +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs index f5239bbd..b053801d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs @@ -104,7 +104,7 @@ private static CommandState Cancel(ShellInterpreter shell) "command-batch-cancelled", "count", count); - return new CommandState { RenderUser = () => ShellInterpreter.WriteLine(message) }; + return new CommandState { RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, message) }; } private static CommandState Status(ShellInterpreter shell) @@ -115,7 +115,7 @@ private static CommandState Status(ShellInterpreter shell) if (batch is null) { root = new JsonObject { ["active"] = false }; - renderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-batch-status-inactive")); + renderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-batch-status-inactive")); } else { @@ -164,7 +164,7 @@ void AddDetail(string labelKey, string value) => AddDetail("command-batch-status-target", $"{batch.DatabaseName}/{batch.ContainerName}"); AddDetail("command-batch-status-partition-key", batch.PartitionKeyArgument); AddDetail("command-batch-status-operation-count", batch.Operations.Count.ToString(CultureInfo.InvariantCulture)); - AnsiConsole.Write(details); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, details); if (batch.Operations.Count == 0) { @@ -184,7 +184,7 @@ void AddDetail(string labelKey, string value) => Theme.FormatTableValue(Markup.Escape(operation.Id ?? string.Empty))); } - AnsiConsole.Write(operations); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, operations); } private static CommandState Show(ShellInterpreter shell) @@ -278,7 +278,7 @@ private async Task BeginAsync(ShellInterpreter shell, Cancellation databaseName!, "container", containerName!); - return new CommandState { RenderUser = () => ShellInterpreter.WriteLine(message) }; + return new CommandState { RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, message) }; } private CommandState Add(ShellInterpreter shell, CommandState commandState) @@ -306,7 +306,7 @@ private CommandState Add(ShellInterpreter shell, CommandState commandState) specs.Count, "total", batch.Operations.Count); - return new CommandState { RenderUser = () => ShellInterpreter.WriteLine(message) }; + return new CommandState { RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, message) }; } private async Task ExecuteBatchAsync(ShellInterpreter shell, CancellationToken token) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs index 68040c96..addda5bc 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs @@ -92,7 +92,7 @@ public static async Task ExecuteAsync( { RequestCharge = response.RequestCharge, }; - errorState.RenderUser = () => ShellInterpreter.WriteLine(errorMessage); + errorState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, errorMessage); return errorState; } @@ -103,7 +103,7 @@ internal static CommandState CreateResultState(JsonElement summary, string messa Result = new ShellJson(summary), RequestCharge = requestCharge, }; - state.RenderUser = () => ShellInterpreter.WriteLine(message); + state.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, message); return state; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs index 8756412e..843f16d3 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs @@ -71,15 +71,7 @@ private enum BucketActionKind public bool? Yes { get; init; } public static bool CheckBucket(int bucket) - { - var isValid = bucket >= 0 && bucket <= 5; - if (!isValid) - { - AnsiConsole.MarkupLine(MessageService.GetString("error-invalid_bucket_value", new Dictionary { { "bucket", bucket } })); - } - - return isValid; - } + => bucket >= 0 && bucket <= 5; public override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) => shell.State.AcceptAsync(this, shell, token); @@ -138,7 +130,7 @@ private static bool ConfirmWrite(ShellInterpreter shell, bool yes, string summar return true; } - AnsiConsole.MarkupLine(MessageService.GetArgsString(summaryKey, args)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString(summaryKey, args)); return ShellInterpreter.Confirm("command-bucket-confirm"); } @@ -177,7 +169,7 @@ private static CommandState BuildShowResult(ConnectedState state, ThroughputBuck { if (view.Buckets.Count == 0) { - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-bucket-no_limits", "resource", Markup.Escape(view.ResourceName))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-bucket-no_limits", "resource", Markup.Escape(view.ResourceName))); } else { @@ -191,7 +183,7 @@ private static CommandState BuildShowResult(ConnectedState state, ThroughputBuck Theme.FormatTableValue(bucket.MaxThroughputPercentage.ToString(CultureInfo.InvariantCulture) + "%")); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } WriteClientSelection(clientBucket); @@ -203,11 +195,11 @@ private static void WriteClientSelection(int? clientBucket) { if (clientBucket.HasValue) { - AnsiConsole.MarkupLine(MessageService.GetString("command-bucket-current", new Dictionary { { "bucket", Theme.FormatTableValue(clientBucket.Value.ToString(CultureInfo.InvariantCulture)) } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-current", new Dictionary { { "bucket", Theme.FormatTableValue(clientBucket.Value.ToString(CultureInfo.InvariantCulture)) } })); } else { - AnsiConsole.MarkupLine(MessageService.GetString("command-bucket-no_bucket")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-no_bucket")); } } @@ -254,18 +246,20 @@ private CommandState RunClientCommand(CosmosClient client) int bucket = int.Parse(this.Action!.Trim(), CultureInfo.InvariantCulture); if (!CheckBucket(bucket)) { - return new CommandState(); + throw new CommandException( + "bucket", + MessageService.GetString("error-invalid_bucket_value", new Dictionary { { "bucket", bucket } })); } if (bucket == 0) { client.ClientOptions.ThroughputBucket = null; - AnsiConsole.MarkupLine(MessageService.GetString("command-bucket-reset_bucket")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-reset_bucket")); } else { client.ClientOptions.ThroughputBucket = bucket; - AnsiConsole.MarkupLine(MessageService.GetString("command-bucket-switched_bucket", new Dictionary { { "bucket", Theme.FormatTableValue(bucket.ToString(CultureInfo.InvariantCulture)) } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-switched_bucket", new Dictionary { { "bucket", Theme.FormatTableValue(bucket.ToString(CultureInfo.InvariantCulture)) } })); } } else @@ -330,7 +324,7 @@ private async Task ExecuteSetAsync(ConnectedState state, ShellInte return new CommandState { Result = new ShellJson(cancelledDoc.RootElement.Clone()), - RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-bucket-cancelled")), + RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-cancelled")), }; } @@ -339,7 +333,7 @@ private async Task ExecuteSetAsync(ConnectedState state, ShellInte var renderSet = setResult.RenderUser; setResult.RenderUser = () => { - ShellInterpreter.WriteLine(MessageService.GetString("command-bucket-set_done")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-set_done")); renderSet?.Invoke(); }; return setResult; @@ -355,7 +349,7 @@ private async Task ExecuteClearAsync(ConnectedState state, ShellIn return new CommandState { Result = new ShellJson(cancelledDoc.RootElement.Clone()), - RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-bucket-cancelled")), + RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-cancelled")), }; } @@ -364,7 +358,7 @@ private async Task ExecuteClearAsync(ConnectedState state, ShellIn var renderClear = clearResult.RenderUser; clearResult.RenderUser = () => { - ShellInterpreter.WriteLine(MessageService.GetString("command-bucket-clear_done")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-clear_done")); renderClear?.Invoke(); }; return clearResult; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs index 0bdc6c04..8457ef1d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs @@ -214,7 +214,7 @@ internal CommandState Build( private void RenderTable(string action, string? databaseName, string? containerName, string decision, string method, int? statusCode, string? note) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-can-i-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-can-i-title"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty); @@ -233,11 +233,11 @@ private void RenderTable(string action, string? databaseName, string? containerN table.AddRow(MessageService.GetString("command-can-i-status"), Theme.FormatTableValue(code.ToString())); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); if (!string.IsNullOrEmpty(note)) { - AnsiConsole.MarkupLine(Theme.FormatMuted(note)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(note)); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs index 28143ac9..b86de294 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CdCommand.cs @@ -83,7 +83,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co SetState(shell, new ConnectedState(connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { - ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_connected_state")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-cd-changed_to_connected_state")); } CosmosCompleteCommand.ClearContainers(); @@ -100,7 +100,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co SetState(shell, new DatabaseState(targetDatabase, connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { - ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_db", new Dictionary { { "db", targetDatabase } })); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-cd-changed_to_db", new Dictionary { { "db", targetDatabase } })); } CosmosCompleteCommand.ClearContainers(); @@ -112,7 +112,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co SetState(shell, new ContainerState(targetContainer, targetDatabase, connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { - ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); } CosmosCompleteCommand.ClearContainers(); @@ -132,7 +132,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co SetState(shell, new ContainerState(targetContainer, dbName, connectedState.Client, connectedState.ArmContext)); if (!this.Quiet) { - ShellInterpreter.WriteLine(MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-cd-changed_to_container", new Dictionary { { "container", targetContainer } })); } CosmosCompleteCommand.ClearContainers(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ClsCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ClsCommand.cs index fd808511..f13bbcbb 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ClsCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ClsCommand.cs @@ -18,7 +18,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS { try { - AnsiConsole.Clear(); + shell.Output.ClearScreen(); } catch (IOException) { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs index 98170d1b..8022cf30 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs @@ -235,7 +235,7 @@ private async Task SetAsync(ConnectedState state, string databaseN } var view = await CosmosResourceFacade.ReplaceConflictResolutionPolicyAsync(state, databaseName, containerName, update, token); - ShellInterpreter.WriteLine(MessageService.GetString("command-conflict-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-conflict-updated")); return BuildResult(containerName, view); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs index f75980d6..32e7b64c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs @@ -75,7 +75,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co var previousEndpoint = cs.Client.Endpoint.Host; if (!isMachineMode) { - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-connect-switching", "endpoint", previousEndpoint)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString("command-connect-switching", "endpoint", previousEndpoint)); } } @@ -152,8 +152,8 @@ internal static bool TryGetPrincipalIdFromRbacException(Exception e, out string? internal static void AskForRBacPermissions(string principalId, string permission) { - AnsiConsole.Markup(Theme.FormatError(MessageService.GetString("error")) + " "); - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-connect-rbac-error", "id", principalId, "permission", permission)); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Error, Theme.FormatError(MessageService.GetString("error")) + " "); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString("command-connect-rbac-error", "id", principalId, "permission", permission)); } /// @@ -164,7 +164,7 @@ internal static void AskForRBacPermissions(string principalId, string permission /// internal static void PrintConnectUsageHint(ShellInterpreter shell) { - AnsiConsole.MarkupLine(Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-header"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-header"))); if (shell.App.Commands.TryGetValue("connect", out var factory)) { @@ -184,17 +184,17 @@ internal static void PrintConnectUsageHint(ShellInterpreter shell) } var highlighted = shell.BuildHighlightedMarkup(example); - AnsiConsole.MarkupLine($" {highlighted}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $" {highlighted}"); if (!string.IsNullOrWhiteSpace(description)) { - AnsiConsole.MarkupLine($" {Theme.FormatMuted(description)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $" {Theme.FormatMuted(description)}"); } shown++; } } - AnsiConsole.MarkupLine(Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-footer"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Markup.Escape(MessageService.GetString("command-connect-not_connected-usage-footer"))); } private static async Task PrintConnectionInfoAsync(ShellInterpreter shell, CommandState commandState, CancellationToken token) @@ -209,7 +209,7 @@ private static async Task PrintConnectionInfoAsync(ShellInterprete commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(notConnectedJson)); commandState.RenderUser = () => { - AnsiConsole.MarkupLine(MessageService.GetString("command-connect-not_connected")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-connect-not_connected")); PrintConnectUsageHint(shell); }; return commandState; @@ -258,7 +258,7 @@ private static async Task PrintConnectionInfoAsync(ShellInterprete }; commandState.RenderUser = () => { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-connect-info-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-connect-info-title"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty); @@ -282,7 +282,7 @@ private static async Task PrintConnectionInfoAsync(ShellInterprete // Show current navigation state table.AddRow(MessageService.GetString("command-connect-info-location"), Theme.ConnectedStatePromt(currentLocation)); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); }; return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DirCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DirCommand.cs index 47780690..1a19f4b7 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DirCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DirCommand.cs @@ -149,11 +149,11 @@ private void RenderEntries(List entries) // Simple list mode: just show the name if (entry.IsDirectory) { - AnsiConsole.MarkupLine(Theme.FormatDirectory(entry.Name + "/")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatDirectory(entry.Name + "/")); } else { - AnsiConsole.WriteLine(entry.Name); + ShellInterpreter.Instance.Output.RenderLine(ShellMessageKind.Result, entry.Name); } } else @@ -161,19 +161,19 @@ private void RenderEntries(List entries) // Detailed mode: show date, size, and name if (entry.IsDirectory) { - AnsiConsole.MarkupLine(Theme.FormatDirectory(entry.Name + "/")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatDirectory(entry.Name + "/")); } else { var sizeStr = FormatFileSize(entry.Size ?? 0); - AnsiConsole.MarkupLine($"{Theme.FormatMuted($"{entry.LastModified:yyyy-MM-dd HH:mm}")} {Theme.FormatTableValue(sizeStr.PadLeft(10))} {Markup.Escape(entry.Name)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $"{Theme.FormatMuted($"{entry.LastModified:yyyy-MM-dd HH:mm}")} {Theme.FormatTableValue(sizeStr.PadLeft(10))} {Markup.Escape(entry.Name)}"); } } } if (!this.ListOnly) { - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-dir-summary", "fileCount", entries.Count(e => !e.IsDirectory), diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DisconnectCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DisconnectCommand.cs index 97ecd38b..aabbcfd4 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DisconnectCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DisconnectCommand.cs @@ -30,7 +30,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS ["endpoint"] = endpoint, }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(jsonResult)); - commandState.RenderUser = () => AnsiConsole.MarkupLine(MessageService.GetArgsString("command-disconnect-success", "endpoint", endpoint)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-disconnect-success", "endpoint", endpoint)); } else { @@ -40,7 +40,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS ["disconnected"] = false, }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(jsonResult)); - commandState.RenderUser = () => AnsiConsole.MarkupLine(MessageService.GetString("command-disconnect-not_connected")); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-disconnect-not_connected")); } return Task.FromResult(commandState); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DoctorCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DoctorCommand.cs index 46cc7cb0..22cf0d4b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DoctorCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/DoctorCommand.cs @@ -478,7 +478,7 @@ internal CommandState CreateResult(List checks, CommandState comman if (this.IncludeIdentity) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(Message("who-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(Message("who-heading"))); } var grid = new Grid(); @@ -506,9 +506,9 @@ internal CommandState CreateResult(List checks, CommandState comman } } - AnsiConsole.Write(grid); - AnsiConsole.WriteLine(); - AnsiConsole.MarkupLine($"{Theme.FormatSectionHeader(Message("summary"))}: {summary.pass} {FormatStatus("PASS")} | {summary.warn} {FormatStatus("WARN")} | {summary.fail} {FormatStatus("FAIL")} | {summary.skip} {FormatStatus("SKIP")} | {durationMs.ToString(CultureInfo.InvariantCulture)} ms | {FormatCharge(result.RequestCharge)} RU"); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, grid); + ShellInterpreter.Instance.Output.RenderLine(ShellMessageKind.Result); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $"{Theme.FormatSectionHeader(Message("summary"))}: {summary.pass} {FormatStatus("PASS")} | {summary.warn} {FormatStatus("WARN")} | {summary.fail} {FormatStatus("FAIL")} | {summary.skip} {FormatStatus("SKIP")} | {durationMs.ToString(CultureInfo.InvariantCulture)} ms | {FormatCharge(result.RequestCharge)} RU"); }; return result; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/EditCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/EditCommand.cs index 59db19e7..559c0561 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/EditCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/EditCommand.cs @@ -34,8 +34,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS if (Console.IsInputRedirected || !string.IsNullOrEmpty(shell.CurrentScriptFileName)) { var message = MessageService.GetString("command-edit-not-interactive"); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message))); + throw new CommandException("edit", message); } var path = this.FilePath; @@ -56,19 +55,17 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException) { var message = MessageService.GetArgsString("command-edit-create-failed", "path", path, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message, ex))); + throw new CommandException("edit", message, ex); } var editor = ExternalEditor.Resolve(null); if (editor is null) { var message = MessageService.GetString("command-edit-no-editor"); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message))); + throw new CommandException("edit", message); } - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-edit-launching", "path", path, @@ -97,8 +94,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS path, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message, ex))); + throw new CommandException("edit", message, ex); } if (exitCode != 0) @@ -109,12 +105,11 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS editor.DisplayName, "code", exitCode); - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); - return Task.FromResult(new ErrorCommandState(new CommandException("edit", message))); + throw new CommandException("edit", message); } commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "file", path, edited = true })); - commandState.RenderUser = () => AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString("command-edit-saved", "path", path))); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetArgsString("command-edit-saved", "path", path))); return Task.FromResult(commandState); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs index a2195c44..407c1e36 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs @@ -94,7 +94,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co var (count, charge) = await ExecuteExportAsync(container, query, max, format, filePath, this.Force == true, token); - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-export-success", "count", count, diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/FtabCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/FtabCommand.cs index 3ab2984a..61552d20 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/FtabCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/FtabCommand.cs @@ -409,7 +409,7 @@ private static void RenderSpectreTable(IReadOnlyList headers, IReadOnlyL table.AddRow(row.Select((cell, index) => FormatStyledCell(headers[index], cell, colorizeRules)).ToArray()); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } private static string RenderPlainText(IReadOnlyList headers, IReadOnlyList> rows) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs index 3ab3971f..f83ca567 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs @@ -65,9 +65,7 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo } // Neither command nor statement - AnsiConsole.Markup(Theme.FormatError(MessageService.GetString("error")) + " "); - ShellInterpreter.WriteLine(MessageService.GetString("error-command-not-found", MessageService.Args("command", cmdStr))); - return new ErrorCommandState(new CommandException("help", cmdStr + " not found.")); + throw new CommandException("help", MessageService.GetString("error-command-not-found", MessageService.Args("command", cmdStr))); } if (cmd == null) @@ -82,69 +80,69 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo // Plain output (no colors, no panels) if (!string.IsNullOrEmpty(cmd.Description)) { - ShellInterpreter.WriteLine(cmd.Description); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, cmd.Description); } - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(MessageService.GetString("help-usage", new System.Collections.Generic.Dictionary { ["command"] = cmd.CommandName }) + " " + BuildPlainUsage(cmd)); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-usage", new System.Collections.Generic.Dictionary { ["command"] = cmd.CommandName }) + " " + BuildPlainUsage(cmd)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); if (cmd.Aliases.Count > 0) { - ShellInterpreter.WriteLine($"{MessageService.GetString("help-aliases")} {string.Join(", ", cmd.Aliases)}"); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $"{MessageService.GetString("help-aliases")} {string.Join(", ", cmd.Aliases)}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } if (cmd.Parameters.Count > 0) { - ShellInterpreter.WriteLine(MessageService.GetString("help-arguments")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-arguments")); foreach (var p in cmd.Parameters) { var name = p.Name.FirstOrDefault() ?? string.Empty; - ShellInterpreter.Write(" "); - ShellInterpreter.Write(p.IsRequired ? name : $"[{name}]"); - ShellInterpreter.Write(" "); + ShellInterpreter.Instance.Output.Write(ShellMessageKind.Result, " "); + ShellInterpreter.Instance.Output.Write(ShellMessageKind.Result, p.IsRequired ? name : $"[{name}]"); + ShellInterpreter.Instance.Output.Write(ShellMessageKind.Result, " "); var desc = p.GetDescription(cmd.CommandName) ?? string.Empty; if (!p.IsRequired) { - ShellInterpreter.Write($"{MessageService.GetString("help-optional")} "); + ShellInterpreter.Instance.Output.Write(ShellMessageKind.Result, $"{MessageService.GetString("help-optional")} "); } - ShellInterpreter.WriteLine(desc); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, desc); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } if (cmd.Options.Count > 0) { - ShellInterpreter.WriteLine(MessageService.GetString("help-options")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-options")); foreach (var opt in cmd.Options) { var names = string.Join(", ", opt.Name.Select(n => "-" + n)); var desc = opt.GetDescription(cmd.CommandName) ?? string.Empty; - ShellInterpreter.WriteLine($" {names} {desc}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {names} {desc}"); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } // Use shared examples collection to avoid shadowing var plainExamples = cmd.ExamplesWithDescriptions; if (plainExamples.Count > 0) { - ShellInterpreter.WriteLine(MessageService.GetString("help-examples")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-examples")); for (int i = 0; i < plainExamples.Count; i++) { var (ex, desc) = plainExamples[i]; if (!string.IsNullOrEmpty(desc)) { - ShellInterpreter.WriteLine($" {i + 1}. {desc}"); - ShellInterpreter.WriteLine($" {ex}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {i + 1}. {desc}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {ex}"); } else { - ShellInterpreter.WriteLine($" {i + 1}. {ex}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {i + 1}. {ex}"); } } } @@ -154,31 +152,31 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo // Styled output if (!string.IsNullOrEmpty(cmd.Description)) { - AnsiConsole.MarkupLine($"{INDENT}{Theme.FormatHelpHeader(cmd.Description)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $"{INDENT}{Theme.FormatHelpHeader(cmd.Description)}"); } if (cmd.Aliases.Count > 0) { - AnsiConsole.MarkupLine($"{INDENT}[dim]{Markup.Escape(MessageService.GetString("help-aliases"))} {Markup.Escape(string.Join(", ", cmd.Aliases))}[/]"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $"{INDENT}[dim]{Markup.Escape(MessageService.GetString("help-aliases"))} {Markup.Escape(string.Join(", ", cmd.Aliases))}[/]"); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); WriteSectionHeader(MessageService.GetString("help-usage-heading")); - AnsiConsole.Markup(INDENT + Theme.FormatCommand(cmd.CommandName) + " "); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, INDENT + Theme.FormatCommand(cmd.CommandName) + " "); } if (!plain && cmd?.Options != null) { foreach (var p in cmd.Options) { - AnsiConsole.Markup(INDENT + "[[" + Theme.FormatHelpName("-" + (p.Name.FirstOrDefault() ?? string.Empty))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, INDENT + "[[" + Theme.FormatHelpName("-" + (p.Name.FirstOrDefault() ?? string.Empty))); if (!p.PropertyInfo.PropertyType.IsAssignableFrom(typeof(bool))) { - AnsiConsole.Markup($" [dim]{MessageService.GetString("help-arg")}[/]"); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, $" [dim]{MessageService.GetString("help-arg")}[/]"); } - AnsiConsole.Markup("]] "); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "]] "); } } @@ -194,16 +192,16 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo if (p.IsRequired) { - AnsiConsole.Markup(INDENT + Theme.FormatHelpName(name) + " "); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, INDENT + Theme.FormatHelpName(name) + " "); } else { - AnsiConsole.Markup(INDENT + "[[" + Theme.FormatHelpName(name) + "]] "); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, INDENT + "[[" + Theme.FormatHelpName(name) + "]] "); } } - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); if (cmd.Parameters.Count > 0) { @@ -241,8 +239,8 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo table.AddRow(INDENT + nameDisplay, descDisplay); } - AnsiConsole.Write(table); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } } @@ -278,24 +276,24 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo table.AddRow(INDENT + Theme.FormatHelpName(sb.ToString()), descDisplay); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } var examples = cmd?.ExamplesWithDescriptions; if (!plain && examples != null && examples.Count > 0) { - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); WriteSectionHeader(MessageService.GetString("help-examples-heading")); for (int i = 0; i < examples.Count; i++) { var (example, description) = examples[i]; if (!string.IsNullOrWhiteSpace(description)) { - AnsiConsole.MarkupLine(INDENT + $"{Theme.FormatHelpAccent("\u25b6")} {i + 1}. {Theme.FormatHelpDescription(description)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, INDENT + $"{Theme.FormatHelpAccent("\u25b6")} {i + 1}. {Theme.FormatHelpDescription(description)}"); } else { - AnsiConsole.MarkupLine(INDENT + $"{Theme.FormatHelpAccent("\u25b6")} {i + 1}."); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, INDENT + $"{Theme.FormatHelpAccent("\u25b6")} {i + 1}."); } var parser = new StatementParser(example); @@ -328,11 +326,11 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo Border = BoxBorder.None, Padding = new Padding(4, 0, 0, 0), }; - AnsiConsole.Write(panel); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, panel); if (i < examples.Count - 1) { - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } } } @@ -496,8 +494,8 @@ private static void PrintCommandCategory(string categoryName, List s.Name)) { - ShellInterpreter.WriteLine($" {s.Name}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {s.Name}"); if (!string.IsNullOrWhiteSpace(s.Description)) { - ShellInterpreter.WriteLine($" {s.Description}"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, $" {s.Description}"); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } } else @@ -711,8 +709,8 @@ private static void PrintStatementHelps(bool plain) BorderStyle = Theme.GetHelpBorderStyle(), Padding = new Padding(1, 0, 1, 0), }; - AnsiConsole.Write(stmtPanel); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, stmtPanel); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); var table = new Table() .Border(TableBorder.None) @@ -729,8 +727,8 @@ private static void PrintStatementHelps(bool plain) table.AddRow(INDENT + Theme.FormatKeyword(s.Name), desc); } - AnsiConsole.Write(table); - AnsiConsole.MarkupLine(string.Empty); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, string.Empty); } } @@ -783,7 +781,7 @@ private static void WriteSectionHeader(string title) Justification = Justify.Left, Style = Theme.GetMutedStyle(), }; - AnsiConsole.Write(rule); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, rule); } private static string BuildPlainUsage(CommandFactory cmd) @@ -827,21 +825,21 @@ private static void PrintPlainCategory(string title, List comman return; } - ShellInterpreter.WriteLine(title + ":"); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, title + ":"); foreach (var c in commands) { - ShellInterpreter.WriteLine(" " + c.CommandName + " - " + (c.Description ?? string.Empty)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, " " + c.CommandName + " - " + (c.Description ?? string.Empty)); } - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } private void RenderCommandList(CommandRunner app) { if (this.Plain) { - ShellInterpreter.WriteLine(MessageService.GetString("help-available-commands")); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("help-available-commands")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } else { @@ -852,8 +850,8 @@ private void RenderCommandList(CommandRunner app) BorderStyle = Theme.GetHelpBorderStyle(), Padding = new Padding(1, 0, 1, 0), }; - AnsiConsole.Write(headerPanel); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, headerPanel); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } // Group commands by category (basic heuristic based on name patterns) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs index 4d1ace93..4e6815b4 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs @@ -497,39 +497,42 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co var (successCount, failCount, charge) = await ExecuteImportAsync(filePath, format, mode, container, continueOnError, dryRun, partitionKeySegments, concurrency, token); - if (dryRun) + if (!shell.IsMachineMode) { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-dry-run-success", - "count", - successCount)); - } - else if (failCount == 0) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-success", - "count", - successCount, - "charge", - charge.ToString("F2", CultureInfo.InvariantCulture))); - } - else if (successCount > 0) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-success-partial", - "success", - successCount, - "failed", - failCount, - "charge", - charge.ToString("F2", CultureInfo.InvariantCulture))); - } - else - { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "command-import-all-failed", - "count", - failCount)); + if (dryRun) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( + "command-import-dry-run-success", + "count", + successCount)); + } + else if (failCount == 0) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( + "command-import-success", + "count", + successCount, + "charge", + charge.ToString("F2", CultureInfo.InvariantCulture))); + } + else if (successCount > 0) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString( + "command-import-success-partial", + "success", + successCount, + "failed", + failCount, + "charge", + charge.ToString("F2", CultureInfo.InvariantCulture))); + } + else + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString( + "command-import-all-failed", + "count", + failCount)); + } } if (failCount > 0) @@ -660,7 +663,10 @@ void Record((double Charge, string? Error) result) else { failed++; - ShellInterpreter.WriteLine(result.Error); + if (!ShellInterpreter.Instance.IsMachineMode) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, result.Error); + } } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs index 27933f67..0177ec69 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs @@ -418,7 +418,7 @@ private async Task WritePolicyAsync(ConnectedState state, string d throw new CommandException("index", MessageService.GetString("command-index-error-invalid_policy"), ex); } - ShellInterpreter.WriteLine(MessageService.GetString("command-index-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-index-updated")); return BuildResult(updatedJson); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs index 11a78de3..fe493f69 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs @@ -196,17 +196,17 @@ private static void AskForRBacPermissions(string principalId, string request, st var line = lines[i]; if (i == 0) { - AnsiConsole.Markup(indent + Theme.FormatError(MessageService.GetString("error")) + " "); - ShellInterpreter.WriteLine(line); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, indent + Theme.FormatError(MessageService.GetString("error")) + " "); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, line); } else if (line.Length == 0) { - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); } else { - AnsiConsole.Markup(indent); - ShellInterpreter.WriteLine(line); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, indent); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, line); } } } @@ -215,9 +215,9 @@ private static async Task WriteAccountDatabaseBreakdownAsync(ConnectedState stat { if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-account-databases-heading"))); - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-account-detailed-cost-note"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-account-databases-heading"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-account-detailed-cost-note"))); } long totalContainers = 0; @@ -277,7 +277,7 @@ private static async Task WriteAccountDatabaseBreakdownAsync(ConnectedState stat totalsTable.AddRow(MessageService.GetString("command-stats-account-label-total-containers"), Theme.FormatTableValue(totalContainers.ToString(CultureInfo.InvariantCulture))); totalsTable.AddRow(MessageService.GetString("command-stats-account-label-total-documents"), Theme.FormatTableValue(FormatCount(totalDocuments))); totalsTable.AddRow(MessageService.GetString("command-stats-account-label-total-size"), Theme.FormatTableValue(FormatSize(totalSizeKb))); - AnsiConsole.Write(totalsTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, totalsTable); if (rows.Count == 0) { @@ -298,7 +298,7 @@ private static async Task WriteAccountDatabaseBreakdownAsync(ConnectedState stat Theme.FormatTableValue(FormatSize(row.SizeKb))); } - AnsiConsole.Write(databaseTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, databaseTable); } internal static void AddSessionUsage(ShellInterpreter shell, Dictionary mcpTable, bool renderOutput) @@ -324,7 +324,7 @@ internal static void AddSessionUsage(ShellInterpreter shell, Dictionary ReadContainerUsageAsync(Container container, CancellationToken token) @@ -396,7 +396,7 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio return; } - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-throughput-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-throughput-heading"))); if (throughputError is not null) { @@ -406,14 +406,14 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio } else { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatError(throughputError)); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatError(throughputError)); } } else if (serverless) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-scale-serverless"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-scale-serverless"))); } else if (min.HasValue || max.HasValue) { @@ -430,12 +430,12 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio table.AddRow(MessageService.GetString("command-stats-throughput-min"), Theme.FormatTableValue(renderedMinValue.ToString(CultureInfo.InvariantCulture))); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } else { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-database-shared-throughput-none"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-database-shared-throughput-none"))); } } @@ -443,9 +443,9 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio { if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-partitions-heading"))); - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-partitions-cost-note"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-partitions-heading"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-partitions-cost-note"))); } var ranges = await container.GetFeedRangesAsync(token); @@ -488,14 +488,14 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio if (table is not null) { - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } if (renderOutput && total > 0 && counts.Count > 1) { double largestShare = (double)counts.Max() / total * 100; - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetArgsString( "command-stats-partitions-skew", "percent", string.Create(CultureInfo.InvariantCulture, $"{largestShare:0.#}")))); @@ -508,9 +508,9 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio { if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-detailed-heading"))); - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-detailed-cost-note"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-detailed-heading"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-detailed-cost-note"))); } var result = new List>(); @@ -518,8 +518,8 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio { if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-stats-na"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-stats-na"))); } return result; @@ -583,7 +583,7 @@ private static async Task WriteDatabaseThroughputAsync(Database database, Dictio if (table is not null) { - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } return result; @@ -716,7 +716,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she // Scale section - fail gracefully if it cannot be read if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-scale-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-scale-heading"))); } switch (view.Throughput) @@ -726,8 +726,8 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she var maxDisplay = view.MaxThroughput?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? MessageService.GetString("command-settings-na"); if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-settings-scale-usage", "min", minDisplay, "max", maxDisplay)); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-settings-scale-usage", "min", minDisplay, "max", maxDisplay)); } if (view.MinThroughput.HasValue) @@ -744,16 +744,16 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she case ThroughputAvailability.NotConfigured: if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-na"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-na"))); } break; case ThroughputAvailability.Serverless: if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-scale-serverless"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-scale-serverless"))); } break; @@ -766,8 +766,8 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she } else { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatError(view.ThroughputErrorMessage ?? string.Empty)); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatError(view.ThroughputErrorMessage ?? string.Empty)); } } @@ -776,7 +776,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she if (renderOutput) { - AnsiConsole.MarkupLine(string.Empty); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, string.Empty); } mcpTable["id"] = view.ContainerName; @@ -804,7 +804,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she Table? table = null; if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-title"))); table = new Table(); table.AddColumns(string.Empty, string.Empty); table.AddRow(MessageService.GetString("command-settings-ttl-label"), Theme.FormatTableValue(ttl)); @@ -827,13 +827,13 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she { table.AddRow(MessageService.GetString("command-settings-partition-key-label"), Theme.FormatTableValue(string.Join(',', view.PartitionKeyPaths))); table.HideHeaders(); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } // Full Text Policy section - show N/A when policy is unset if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-fulltext-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-fulltext-title"))); } if (view.FullTextPolicy is { } fullText) @@ -872,19 +872,19 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she if (fullTextTable is not null) { fullTextTable.HideHeaders(); - AnsiConsole.Write(fullTextTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, fullTextTable); } } else if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-na"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-na"))); } // Indexing Policy section - compact summary; use 'index show' for the full policy if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-indexing-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-indexing-title"))); } if (view.IndexingPolicy is { } indexing) @@ -931,7 +931,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she } indexingTable.HideHeaders(); - AnsiConsole.Write(indexingTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, indexingTable); } mcpTable["indexingPolicy"] = new Dictionary @@ -947,8 +947,8 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she } else if (renderOutput) { - AnsiConsole.Markup("\t"); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-settings-na"))); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, "\t"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-settings-na"))); } // Usage section - document count and storage size @@ -965,7 +965,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-usage-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-usage-heading"))); var usageTable = new Table(); usageTable.AddColumns(string.Empty, string.Empty); @@ -978,7 +978,7 @@ private async Task ShowContainerSettingsAsync(ShellInterpreter she } usageTable.AddRow(MessageService.GetString("command-stats-label-total-size"), Theme.FormatTableValue(FormatSize(usage.TotalSizeKb))); - AnsiConsole.Write(usageTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, usageTable); } if (this.Partitions) @@ -1043,7 +1043,7 @@ private async Task ShowDatabaseSettingsAsync(ShellInterpreter shel if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-database-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-database-heading"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty); @@ -1052,14 +1052,14 @@ private async Task ShowDatabaseSettingsAsync(ShellInterpreter shel table.AddRow(MessageService.GetString("command-stats-database-label-container-count"), Theme.FormatTableValue(rows.Count.ToString(CultureInfo.InvariantCulture))); table.AddRow(MessageService.GetString("command-stats-database-label-total-documents"), Theme.FormatTableValue(FormatCount(totalDocuments))); table.AddRow(MessageService.GetString("command-stats-database-label-total-size"), Theme.FormatTableValue(FormatSize(totalSizeKb))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } await WriteDatabaseThroughputAsync(database, mcpTable, renderOutput, token); if (renderOutput && this.Detailed && rows.Count > 0) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-stats-containers-heading"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-stats-containers-heading"))); var containerTable = new Table(); containerTable.AddColumn(MessageService.GetString("command-stats-containers-col-name")); containerTable.AddColumn(MessageService.GetString("command-stats-containers-col-count")); @@ -1072,7 +1072,7 @@ private async Task ShowDatabaseSettingsAsync(ShellInterpreter shel Theme.FormatTableValue(FormatSize(row.SizeKb))); } - AnsiConsole.Write(containerTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, containerTable); } if (this.Detailed) @@ -1099,7 +1099,7 @@ private async Task PrintOverviewAsync(ShellInterpreter shell, Conn if (renderOutput) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-settings-overview"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-settings-overview"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty, string.Empty, string.Empty); @@ -1107,7 +1107,7 @@ private async Task PrintOverviewAsync(ShellInterpreter shell, Conn table.AddRow(MessageService.GetString("command-settings-uri"), Theme.FormatTableValue(client.Endpoint.ToString()), MessageService.GetString("command-settings-write_locations"), Theme.FormatTableValue(string.Join(", ", acc.WritableRegions.Select(location => location.Name)))); table.AddRow(MessageService.GetString("command-stats-account-label-database-count"), Theme.FormatTableValue(databaseNames.Count.ToString(CultureInfo.InvariantCulture)), string.Empty, string.Empty); table.HideHeaders(); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } var mcpTable = new Dictionary diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs index 6b7ae64c..eb6635c9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ListCommand.cs @@ -113,10 +113,10 @@ async Task IStateVisitor.VisitConn { foreach (var name in list) { - AnsiConsole.MarkupLine(Theme.DatabaseNamePromt(name)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.DatabaseNamePromt(name)); } - AnsiConsole.MarkupLine(MessageService.GetString("command-ls-found_databases", new Dictionary + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-ls-found_databases", new Dictionary { { "count", list.Count }, { "display", Theme.FormatTableValue(list.Count.ToString()) }, @@ -124,7 +124,7 @@ async Task IStateVisitor.VisitConn if (completionList.Count == 0 && hasArmContext) { - AnsiConsole.MarkupLine(Theme.FormatWarning(MessageService.GetString("command-ls-empty_databases_hint"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatWarning(MessageService.GetString("command-ls-empty_databases_hint"))); } }; return result; @@ -183,10 +183,10 @@ private async Task ListDatabaseContainersAsync(ConnectedState stat { foreach (var name in list) { - AnsiConsole.MarkupLine(Theme.ContainerNamePromt(name)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.ContainerNamePromt(name)); } - AnsiConsole.MarkupLine(MessageService.GetString("command-ls-found_containers", new Dictionary + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-ls-found_containers", new Dictionary { { "count", list.Count }, { "display", Theme.FormatTableValue(list.Count.ToString()) }, @@ -195,7 +195,7 @@ private async Task ListDatabaseContainersAsync(ConnectedState stat if (completionList.Count == 0 && hasArmContext) { - AnsiConsole.MarkupLine(Theme.FormatWarning(MessageService.GetString("command-ls-empty_containers_hint", new Dictionary + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatWarning(MessageService.GetString("command-ls-empty_containers_hint", new Dictionary { { "database", databaseName }, }))); @@ -294,8 +294,8 @@ private async Task ListContainerItemsAsync(ConnectedState state, S var limitCount = effectiveMaxItemCount ?? 0; returnState.RenderUser = () => { - AnsiConsole.MarkupLine(JsonOutputHighlighter.BuildMarkup(itemsElement)); - AnsiConsole.MarkupLine(MessageService.GetString("command-ls-found_items", new Dictionary + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, JsonOutputHighlighter.BuildMarkup(itemsElement)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-ls-found_items", new Dictionary { { "count", itemCount }, { "display", Theme.FormatTableValue(itemCount.ToString()) }, @@ -303,7 +303,7 @@ private async Task ListContainerItemsAsync(ConnectedState state, S })); if (reachedLimit) { - AnsiConsole.MarkupLine(MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", limitCount } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", limitCount } })); } }; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs index ef6a0074..d5386247 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs @@ -180,7 +180,7 @@ private async Task CreateContainerInDatabaseAsync(ConnectedState s id = containerName, created = true, })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-mkcon-CreatedContainer", new Dictionary { { "container", containerName } })); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-mkcon-CreatedContainer", new Dictionary { { "container", containerName } })); return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs index c972f376..e1d927df 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs @@ -61,7 +61,7 @@ async Task IStateVisitor.VisitConn id = databaseName, created = true, })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-mkdb-database_created", new Dictionary { { "db", databaseName } })); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-mkdb-database_created", new Dictionary { { "db", databaseName } })); return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs index d360bbe7..6bbf1f5c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs @@ -147,6 +147,7 @@ private static async Task WriteItemAsync(Container container, stri int replacedCount = 0; int failCount = 0; double charge = 0.0; + var presentResults = !ShellInterpreter.Instance.IsMachineMode; foreach (var element in root.EnumerateArray()) { try @@ -166,50 +167,96 @@ private static async Task WriteItemAsync(Container container, stri else { failCount++; - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-error-status-returned", - "status", - result.StatusCode.ToString())); + if (presentResults) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Error, + MessageService.GetArgsString( + "command-mkitem-error-status-returned", + "status", + result.StatusCode.ToString())); + } } } catch (CosmosException ce) { RequestChargeContext.Record(ce.RequestCharge); failCount++; - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-error-creation-failed", - "status", - ce.StatusCode.ToString(), - "message", - CommandException.GetDisplayMessage(ce))); + if (presentResults) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Error, + MessageService.GetArgsString( + "command-mkitem-error-creation-failed", + "status", + ce.StatusCode.ToString(), + "message", + CommandException.GetDisplayMessage(ce))); + } } } - if (force) + if (presentResults) { - if ((createdCount + replacedCount) > 0 && failCount == 0) + if (force) { - ShellInterpreter.WriteLine( + if ((createdCount + replacedCount) > 0 && failCount == 0) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Information, + MessageService.GetArgsString( + "command-mkitem-upserted-multiple", + "created", + createdCount, + "replaced", + replacedCount, + "charge", + charge.ToString("F2"))); + } + else if ((createdCount + replacedCount) > 0) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Warning, + MessageService.GetArgsString( + "command-mkitem-upserted-partial", + "created", + createdCount, + "replaced", + replacedCount, + "failed", + failCount, + "charge", + charge.ToString("F2"))); + } + else + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Error, + MessageService.GetArgsString( + "command-mkitem-upserted-all-failed", + "count", + failCount)); + } + } + else if (createdCount > 0 && failCount == 0) + { + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Information, MessageService.GetArgsString( - "command-mkitem-upserted-multiple", - "created", + "command-mkitem-created-multiple", + "count", createdCount, - "replaced", - replacedCount, "charge", charge.ToString("F2"))); } - else if ((createdCount + replacedCount) > 0) + else if (createdCount > 0 && failCount > 0) { - ShellInterpreter.WriteLine( + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Warning, MessageService.GetArgsString( - "command-mkitem-upserted-partial", - "created", + "command-mkitem-created-partial", + "success", createdCount, - "replaced", - replacedCount, "failed", failCount, "charge", @@ -217,43 +264,14 @@ private static async Task WriteItemAsync(Container container, stri } else { - ShellInterpreter.WriteLine( + ShellInterpreter.Instance.Output.WriteLine( + ShellMessageKind.Error, MessageService.GetArgsString( - "command-mkitem-upserted-all-failed", + "command-mkitem-created-all-failed", "count", failCount)); } } - else if (createdCount > 0 && failCount == 0) - { - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-created-multiple", - "count", - createdCount, - "charge", - charge.ToString("F2"))); - } - else if (createdCount > 0 && failCount > 0) - { - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-created-partial", - "success", - createdCount, - "failed", - failCount, - "charge", - charge.ToString("F2"))); - } - else - { - ShellInterpreter.WriteLine( - MessageService.GetArgsString( - "command-mkitem-created-all-failed", - "count", - failCount)); - } // Bulk write summary is informational; signal the caller // when one or more items failed so scripted callers don't @@ -284,7 +302,7 @@ private static async Task WriteItemAsync(Container container, stri if (result.StatusCode == System.Net.HttpStatusCode.Created) { var key = force ? "command-mkitem-upserted-created" : "command-mkitem-created-success"; - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( key, "charge", result.RequestCharge.ToString("F2"))); @@ -292,7 +310,7 @@ private static async Task WriteItemAsync(Container container, stri } else if (force && result.StatusCode == System.Net.HttpStatusCode.OK) { - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-mkitem-upserted-replaced", "charge", result.RequestCharge.ToString("F2"))); @@ -300,7 +318,7 @@ private static async Task WriteItemAsync(Container container, stri } else { - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString( "command-mkitem-error-status-returned", "status", result.StatusCode.ToString())); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs index 7abc3bc4..8c26619c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PatchCommand.cs @@ -123,7 +123,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co throw new CommandException("patch", MessageService.GetArgsString("command-patch-error-status-returned", "status", response.StatusCode.ToString())); } - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-patch-success", "charge", response.RequestCharge.ToString("F2"))); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-patch-success", "charge", response.RequestCharge.ToString("F2"))); return new CommandState { Result = new ShellJson(JsonSerializer.SerializeToElement(new diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs index 06fd36d5..68991eed 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs @@ -39,7 +39,7 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS container = shell.State is ContainerState current ? current.ContainerName : null, currentLocation, })); - commandState.RenderUser = () => AnsiConsole.MarkupLine(ShellLocation.GetCurrentLocationMarkup(shell.State)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, ShellLocation.GetCurrentLocationMarkup(shell.State)); return Task.FromResult(commandState); } } \ No newline at end of file diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs index a6cdd7b4..ac681abb 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs @@ -70,7 +70,9 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co { if (this.Bucket.HasValue && !BucketCommand.CheckBucket(this.Bucket.Value)) { - return new CommandState(); + throw new CommandException( + "query", + MessageService.GetString("error-invalid_bucket_value", new Dictionary { { "bucket", this.Bucket.Value } })); } // Get connected state @@ -562,11 +564,11 @@ private static ShellJson BuildExplainJson(string? query, PlanEvaluation evaluati private static void RenderExplain(PlanEvaluation evaluation, double requestCharge, IReadOnlyList messages) { - AnsiConsole.MarkupLine(MessageService.GetString("command-query-explain-header")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-query-explain-header")); foreach (var message in messages) { - AnsiConsole.MarkupLine(Markup.Escape(message)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Markup.Escape(message)); } var table = new Table(); @@ -590,9 +592,9 @@ private static void RenderExplain(PlanEvaluation evaluation, double requestCharg table.AddRow( Theme.FormatHelpName(MessageService.GetString("command-query-explain-charge")), Theme.FormatTableValue(requestCharge.ToString(CultureInfo.InvariantCulture))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); - AnsiConsole.MarkupLine(MessageService.GetString("command-query-explain-estimate_note")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-query-explain-estimate_note")); } private async Task ThrowIfRequestFailedAsync(ResponseMessage response, ShellInterpreter shell) @@ -767,14 +769,14 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn } using var queryDocument = JsonDocument.Parse(responseContent); - ShellInterpreter.WriteLine(MessageService.GetString("command-query-fetched", new Dictionary { { "count", queryDocument.RootElement.GetProperty("_count").ToString() } })); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-query-fetched", new Dictionary { { "count", queryDocument.RootElement.GetProperty("_count").ToString() } })); // Cosmos always returns the RU cost in the response headers, whereas query // metrics (and their TotalRequestCharge) can be null when diagnostics are // unavailable. Accumulate and report from the headers so the charge is always // correct; the detailed metrics payload is built separately from the response. totalRequestCharge += pageRequestCharge; - AnsiConsole.MarkupLine(MessageService.GetString("command-query-request_charge", new Dictionary { { "charge", pageRequestCharge.ToString("F2", CultureInfo.InvariantCulture) } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-query-request_charge", new Dictionary { { "charge", pageRequestCharge.ToString("F2", CultureInfo.InvariantCulture) } })); if (TryReadContinuationToken(response, out var pageContinuationToken)) { @@ -879,7 +881,7 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn table.AddRow(MessageService.GetString("command-query-retrieved"), Theme.FormatTableValue(Fmt("Retrieved document count")), Theme.FormatTableValue(Fmt("Retrieved document size"))); table.AddRow(MessageService.GetString("command-query-output"), Theme.FormatTableValue(Fmt("Output document count")), Theme.FormatTableValue(Fmt("Output document size"))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); table = new Table(); table.AddColumns(string.Empty, string.Empty, string.Empty, string.Empty); @@ -889,13 +891,13 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn table.AddRow(MessageService.GetString("command-query-vm_execution"), Theme.FormatTableValue(Fmt("VMExecution execution time"))); table.AddEmptyRow(); table.AddRow(MessageService.GetString("command-query-total"), Theme.FormatTableValue(Fmt("Total time"))); - AnsiConsole.MarkupLine(MessageService.GetString("command-query-time_label")); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-query-time_label")); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); table = new Table(); table.AddColumns(MessageService.GetString("command-query-index_hit_ratio"), MessageService.GetString("command-query-index_lookup_time")); table.AddRow(Theme.FormatTableValue(Fmt("Index hit ratio")), Theme.FormatTableValue(Fmt("Index lookup time"))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); if (!string.IsNullOrWhiteSpace(response.IndexMetrics)) { @@ -923,7 +925,7 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn } } - AnsiConsole.Write(indexTable); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, indexTable); } } else @@ -949,10 +951,10 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn if (limitReached && effectiveMaxItemCount.HasValue) { - AnsiConsole.MarkupLine(MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", effectiveMaxItemCount.Value } })); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, MessageService.GetString("command-results-limit_reached", new Dictionary { { "count", effectiveMaxItemCount.Value } })); if (!continuationSupported) { - AnsiConsole.MarkupLine(MessageService.GetString("command-query-no_continuation")); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, MessageService.GetString("command-query-no_continuation")); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs index 980e3bbd..9ebca183 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs @@ -98,6 +98,7 @@ private static async Task ReplaceArrayAsync(Container container, int successCount = 0; int failCount = 0; double charge = 0.0; + var presentResults = !ShellInterpreter.Instance.IsMachineMode; foreach (var element in arrayRoot.EnumerateArray()) { @@ -111,21 +112,27 @@ private static async Task ReplaceArrayAsync(Container container, { RequestChargeContext.Record(RequestChargeContext.GetCosmosExceptionCharge(ex)); failCount++; - ShellInterpreter.WriteLine(ex.Message); + if (presentResults) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, ex.Message); + } } } - if (successCount > 0 && failCount == 0) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-replace-success-multiple", "count", successCount, "charge", charge.ToString("F2"))); - } - else if (successCount > 0) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-replace-success-partial", "success", successCount, "failed", failCount, "charge", charge.ToString("F2"))); - } - else + if (presentResults) { - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-replace-all-failed", "count", failCount)); + if (successCount > 0 && failCount == 0) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-replace-success-multiple", "count", successCount, "charge", charge.ToString("F2"))); + } + else if (successCount > 0) + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString("command-replace-success-partial", "success", successCount, "failed", failCount, "charge", charge.ToString("F2"))); + } + else + { + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString("command-replace-all-failed", "count", failCount)); + } } if (failCount > 0) @@ -180,7 +187,7 @@ private static async Task ReplaceOneAsync(Container container, IReadOnly if (printSuccess) { - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-replace-success-single", "charge", response.RequestCharge.ToString("F2"))); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-replace-success-single", "charge", response.RequestCharge.ToString("F2"))); } return response.RequestCharge; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs index a4693ec8..315c813c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs @@ -336,7 +336,8 @@ private async Task RemoveItemsFromContainerAsync(ConnectedState state, if (!TryGetPartitionKeyElements(element, partitionKeyPropertyNames, out var pkElements)) { - AnsiConsole.MarkupLine( + ShellInterpreter.Instance.Output.MarkupLine( + ShellMessageKind.Warning, MessageService.GetString( "command-rm-warning-missing-partition-key", new Dictionary @@ -376,7 +377,7 @@ private async Task RemoveItemsFromContainerAsync(ConnectedState state, }); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(this.CreateResult(totalCount, dryRun))); - commandState.RenderUser = () => AnsiConsole.MarkupLine(renderMessage); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, renderMessage); commandState.RequestCharge = totalCharge > 0 ? totalCharge : null; return new ExitCode(0); @@ -584,7 +585,7 @@ private async Task RemoveExactItemAsync(Container container, Partition } commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(result)); - commandState.RenderUser = () => AnsiConsole.WriteLine(renderMessage); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.RenderLine(ShellMessageKind.Result, renderMessage); commandState.RequestCharge = requestCharge > 0 ? requestCharge : null; return new ExitCode(0); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs index 615e93e3..b4af10a5 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmContainerCommand.cs @@ -94,7 +94,7 @@ private async Task RemoveContainerAsync(ConnectedState state, string d { this.SetOutcome( JsonSerializer.SerializeToElement(new { type = "container", id = containerName, deleted = false, dryRun = true }), - () => AnsiConsole.MarkupLine(MessageService.GetString("command-rmcon-dry-run-plan", new Dictionary { { "container", Markup.Escape(containerName) } }))); + () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-rmcon-dry-run-plan", new Dictionary { { "container", Markup.Escape(containerName) } }))); return 0; } @@ -104,7 +104,7 @@ private async Task RemoveContainerAsync(ConnectedState state, string d CosmosCompleteCommand.ClearContainers(); this.SetOutcome( JsonSerializer.SerializeToElement(new { type = "container", id = containerName, deleted = true, dryRun = false }), - () => AnsiConsole.MarkupLine(MessageService.GetString("command-rmcon-deleted_container", new Dictionary { { "container", Markup.Escape(containerName) } }))); + () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-rmcon-deleted_container", new Dictionary { { "container", Markup.Escape(containerName) } }))); } else { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs index a6095cd0..aa915348 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmDbCommand.cs @@ -88,7 +88,7 @@ private async Task RemoveDatabaseAsync(ConnectedState state, ShellInte { this.SetOutcome( JsonSerializer.SerializeToElement(new { type = "database", id = databaseName, deleted = false, dryRun = true }), - () => AnsiConsole.MarkupLine(MessageService.GetString("command-rmdb-dry-run-plan", new Dictionary { { "db", Markup.Escape(databaseName) } }))); + () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-rmdb-dry-run-plan", new Dictionary { { "db", Markup.Escape(databaseName) } }))); return 0; } @@ -99,7 +99,7 @@ private async Task RemoveDatabaseAsync(ConnectedState state, ShellInte CosmosCompleteCommand.ClearDatabases(); this.SetOutcome( JsonSerializer.SerializeToElement(new { type = "database", id = databaseName, deleted = true, dryRun = false }), - () => AnsiConsole.MarkupLine(MessageService.GetString("command-rmdb-deleted_db", new Dictionary { { "db", Markup.Escape(databaseName) } }))); + () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-rmdb-deleted_db", new Dictionary { { "db", Markup.Escape(databaseName) } }))); } else { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index af7e6fb8..eed0b217 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -248,11 +248,11 @@ internal async Task ListAsync(Container container, ShellInterprete { if (rows.Count == 0) { - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-sproc-list-empty"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-sproc-list-empty"))); } else { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-title"))); var table = new Table(); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-column-id")))); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-sproc-list-column-modified")))); @@ -266,7 +266,7 @@ internal async Task ListAsync(Container container, ShellInterprete Theme.FormatTableValue(row.BodyLength.ToString())); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } }; return commandState; @@ -307,7 +307,7 @@ internal async Task ExistsAsync(Container container, CommandState } commandState.Result = new ShellBool(exists); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( exists ? "command-sproc-exists-yes" : "command-sproc-exists-no", "name", name)); @@ -363,17 +363,17 @@ private async Task CreateAsync(Container container, ShellInterpret if (!string.IsNullOrWhiteSpace(edited)) { - AnsiConsole.Clear(); - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-create-preview", "name", name)); - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(edited); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.ClearScreen(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString("command-sproc-create-preview", "name", name)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, edited); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); } if (string.IsNullOrWhiteSpace(edited) || !ShellInterpreter.Confirm("command-sproc-create-confirm")) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, created = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-create-discarded", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-sproc-create-discarded", "name", name)); return commandState; } @@ -420,7 +420,7 @@ internal async Task WriteCreateAsync(Container container, CommandS } commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, created = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( replaced ? "command-sproc-replaced" : "command-sproc-created", "name", name, @@ -449,7 +449,7 @@ internal async Task ExecAsync(Container container, CommandState co parameters, cancellationToken: token); - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-sproc-executed", "name", name, @@ -475,7 +475,7 @@ internal async Task DeleteAsync(Container container, CommandState var response = await container.Scripts.DeleteStoredProcedureAsync(name, cancellationToken: token); RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, deleted = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-sproc-deleted", "name", name, @@ -515,7 +515,7 @@ private async Task EditAsync(Container container, ShellInterpreter if (string.Equals(newBody, existingBody, StringComparison.Ordinal)) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, changed = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-sproc-edit-unchanged", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-sproc-edit-unchanged", "name", name)); return commandState; } @@ -524,7 +524,7 @@ private async Task EditAsync(Container container, ShellInterpreter RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "sproc", id = name, changed = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-sproc-replaced", "name", name, @@ -554,7 +554,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca { await File.WriteAllTextAsync(tempPath, initialBody, token); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-sproc-edit-launching", "name", name, @@ -585,7 +585,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.WriteLine(MessageService.GetString("command-sproc-edit-wait")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-sproc-edit-wait")); Console.ReadLine(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs index e9200ea6..d274afc7 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs @@ -59,21 +59,29 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS "edit" => Task.FromResult(this.RunEdit(commandState)), "open" => Task.FromResult(this.RunOpen(commandState)), "reload" => Task.FromResult(this.RunReload(commandState)), - _ => Task.FromResult(this.RunUnknownAction(commandState, action)), + _ => throw UnknownAction(action), }; } - private static CommandState ReportUnknownTheme(CommandState commandState, string requested) - { - var message = MessageService.GetArgsString( - "command-theme-unknown", - "name", - Markup.Escape(requested), - "themes", - string.Join(", ", ThemeRegistry.Instance.All.Keys.Select(Markup.Escape))); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); - } + private static CommandException UnknownTheme(string requested) + => new( + "theme", + MessageService.GetArgsString( + "command-theme-unknown", + "name", + requested, + "themes", + string.Join(", ", ThemeRegistry.Instance.All.Keys))); + + private static CommandException UnknownAction(string action) + => new( + "theme", + MessageService.GetArgsString( + "command-theme-unknown-action", + "action", + action, + "actions", + "current, list, show, use (alias: set), load, validate, save, edit, open, reload")); private static string ResolveActiveName() { @@ -141,7 +149,7 @@ private static void RenderThemeSample(ThemeOptions profile, string profileName) try { Theme.Apply(profile); - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-theme-sample-heading", "name", Markup.Escape(profileName.ToLowerInvariant()))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-sample-heading", "name", Markup.Escape(profileName.ToLowerInvariant()))); var table = new Table().HideHeaders(); table.AddColumn(string.Empty); @@ -176,7 +184,7 @@ private static void RenderThemeSample(ThemeOptions profile, string profileName) Row(MessageService.GetString("command-theme-role-help-description"), Theme.FormatHelpDescription(MessageService.GetString("command-theme-sample-description"))); Row(MessageService.GetString("command-theme-role-brackets"), string.Concat(Theme.FormatBracket("{", 0), Theme.FormatBracket("[", 1), Theme.FormatBracket("(", 2), Theme.FormatBracket(")", 2), Theme.FormatBracket("]", 1), Theme.FormatBracket("}", 0))); - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } finally { @@ -187,7 +195,7 @@ private static void RenderThemeSample(ThemeOptions profile, string profileName) private CommandState RunCurrent(CommandState commandState) { var name = ResolveActiveName(); - commandState.RenderUser = () => AnsiConsole.MarkupLine(MessageService.GetArgsString("command-theme-active", "name", Markup.Escape(name))); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-active", "name", Markup.Escape(name))); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", id = name, active = true })); return commandState; } @@ -221,7 +229,7 @@ private CommandState RunList(CommandState commandState) { foreach (var (marker, name, source) in rows) { - AnsiConsole.MarkupLine($" {marker} {Markup.Escape(name)} {Theme.FormatMuted(source)}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $" {marker} {Markup.Escape(name)} {Theme.FormatMuted(source)}"); } }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", values = items })); @@ -233,7 +241,7 @@ private CommandState RunShow(CommandState commandState) var profileName = string.IsNullOrWhiteSpace(this.Name) ? ResolveActiveName() : this.Name; if (!ThemeProfiles.TryGet(profileName, out var profile)) { - return ReportUnknownTheme(commandState, profileName); + throw UnknownTheme(profileName); } commandState.RenderUser = () => RenderThemeSample(profile, profileName); @@ -245,18 +253,17 @@ private CommandState RunUse(CommandState commandState) { if (string.IsNullOrWhiteSpace(this.Name)) { - AnsiConsole.MarkupLine(MessageService.GetString("command-theme-use-missing-name")); - return new ErrorCommandState(new CommandException("theme", MessageService.GetString("command-theme-use-missing-name"))); + throw new CommandException("theme", MessageService.GetString("command-theme-use-missing-name")); } if (!ThemeProfiles.TryGet(this.Name, out var profile)) { - return ReportUnknownTheme(commandState, this.Name); + throw UnknownTheme(this.Name); } Theme.Apply(profile); var name = this.Name.ToLowerInvariant(); - commandState.RenderUser = () => AnsiConsole.MarkupLine(MessageService.GetArgsString("command-theme-applied", "name", Markup.Escape(name))); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-applied", "name", Markup.Escape(name))); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", id = name, applied = true })); return commandState; } @@ -267,8 +274,7 @@ private CommandState RunLoad(CommandState commandState) if (string.IsNullOrWhiteSpace(requested)) { var message = MessageService.GetString("command-theme-load-missing-path"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } var path = ResolveThemePath(requested); @@ -277,7 +283,7 @@ private CommandState RunLoad(CommandState commandState) { var result = ThemeRegistry.Instance.LoadFile(path); Theme.Apply(result.Options); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-loaded", "name", Markup.Escape(result.Name), @@ -285,7 +291,7 @@ private CommandState RunLoad(CommandState commandState) Markup.Escape(result.Source))); foreach (var warning in result.Warnings) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -301,14 +307,12 @@ private CommandState RunLoad(CommandState commandState) } catch (ThemeLoadException ex) { - AnsiConsole.MarkupLine(Theme.FormatError(ex.Message)); - return new ErrorCommandState(new CommandException("theme", ex.Message)); + throw new CommandException("theme", ex.Message); } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { var message = MessageService.GetArgsString("command-theme-load-not-found", "path", path); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } } @@ -317,8 +321,7 @@ private CommandState RunSave(CommandState commandState) if (string.IsNullOrWhiteSpace(this.Name)) { var message = MessageService.GetString("command-theme-save-missing-name"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } string path; @@ -333,8 +336,7 @@ private CommandState RunSave(CommandState commandState) || this.Name != System.IO.Path.GetFileName(this.Name)) { var message = MessageService.GetArgsString("command-theme-save-invalid-name", "name", this.Name); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } path = System.IO.Path.Combine(ThemeFile.DefaultUserThemesDirectory(), this.Name + ".toml"); @@ -347,20 +349,19 @@ private CommandState RunSave(CommandState commandState) if (File.Exists(path) && !this.Force) { var message = MessageService.GetArgsString("command-theme-save-exists", "path", path); - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } try { ThemeFile.Save(this.Name, Theme.Current, path); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-saved", "name", Markup.Escape(this.Name), "path", Markup.Escape(System.IO.Path.GetFullPath(path)))); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString("command-theme-save-hint-reload", "name", this.Name))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString("command-theme-save-hint-reload", "name", this.Name))); commandState.RenderUser = () => { }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { @@ -374,8 +375,7 @@ private CommandState RunSave(CommandState commandState) catch (Exception ex) { var message = MessageService.GetArgsString("command-theme-save-failed", "path", path, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message, ex)); + throw new CommandException("theme", message, ex); } } @@ -406,18 +406,22 @@ private CommandState RunValidateFile(CommandState commandState, string path) try { var result = ThemeRegistry.Instance.ValidateFile(path); - AnsiConsole.MarkupLine(MessageService.GetArgsString( - "command-theme-validated", - "name", - Markup.Escape(result.Name), - "path", - Markup.Escape(result.Source))); - foreach (var warning in result.Warnings) + var strictFailure = this.Strict && result.Warnings.Count > 0; + if (!ShellInterpreter.Instance.IsMachineMode || !strictFailure) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + "command-theme-validated", + "name", + Markup.Escape(result.Name), + "path", + Markup.Escape(result.Source))); + foreach (var warning in result.Warnings) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); + } } - if (this.Strict && result.Warnings.Count > 0) + if (strictFailure) { var strictMessage = MessageService.GetArgsString( "command-theme-validate-strict-failed", @@ -425,8 +429,7 @@ private CommandState RunValidateFile(CommandState commandState, string path) result.Name, "count", result.Warnings.Count); - AnsiConsole.MarkupLine(Theme.FormatError(strictMessage)); - return new ErrorCommandState(new CommandException("theme", strictMessage)); + throw new CommandException("theme", strictMessage); } commandState.RenderUser = () => { }; @@ -443,24 +446,27 @@ private CommandState RunValidateFile(CommandState commandState, string path) } catch (ThemeLoadException ex) { - AnsiConsole.MarkupLine(Theme.FormatError(ex.Message)); - return new ErrorCommandState(new CommandException("theme", ex.Message)); + throw new CommandException("theme", ex.Message); } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { var message = MessageService.GetArgsString("command-theme-load-not-found", "path", path); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } } private CommandState RunValidateDirectory(CommandState commandState, string directory) { var files = ThemeFile.EnumerateThemeFiles(directory); + var presentResults = !ShellInterpreter.Instance.IsMachineMode; if (files.Length == 0) { var emptyMessage = MessageService.GetArgsString("command-theme-validate-no-files", "directory", directory); - AnsiConsole.MarkupLine(Theme.FormatMuted(emptyMessage)); + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(emptyMessage)); + } + commandState.RenderUser = () => { }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { @@ -474,6 +480,7 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire } var fileResults = new List>(); + var pendingWarnings = new List(); var validCount = 0; var invalidCount = 0; @@ -496,17 +503,30 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire if (failedStrict) { invalidCount++; - AnsiConsole.MarkupLine($" {Theme.FormatError("\u2717")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + } } else { validCount++; - AnsiConsole.MarkupLine($" {Theme.FormatHelpAccent("\u2713")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, $" {Theme.FormatHelpAccent("\u2713")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + } } - foreach (var warning in result.Warnings) + if (presentResults) + { + foreach (var warning in result.Warnings) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); + } + } + else { - AnsiConsole.MarkupLine(" " + Theme.FormatWarning(warning)); + pendingWarnings.AddRange(result.Warnings); } } catch (Exception ex) when (ex is ThemeLoadException || ex is FileNotFoundException || ex is DirectoryNotFoundException) @@ -514,22 +534,44 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire invalidCount++; entry["valid"] = false; entry["error"] = ex.Message; - AnsiConsole.MarkupLine($" {Theme.FormatError("\u2717")} {Markup.Escape(System.IO.Path.GetFileName(file))}"); - AnsiConsole.MarkupLine(" " + Theme.FormatError(ex.Message)); + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(System.IO.Path.GetFileName(file))}"); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, " " + Theme.FormatError(ex.Message)); + } } fileResults.Add(entry); } - var summary = MessageService.GetArgsString( - "command-theme-validate-summary", - "valid", - validCount, - "total", - files.Length, - "directory", - Markup.Escape(directory)); - AnsiConsole.MarkupLine(summary); + if (invalidCount > 0) + { + throw new CommandException("theme", MessageService.GetArgsString( + "command-theme-validate-summary", + "valid", + validCount, + "total", + files.Length, + "directory", + directory)); + } + + foreach (var warning in pendingWarnings) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); + } + + if (presentResults) + { + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + "command-theme-validate-summary", + "valid", + validCount, + "total", + files.Length, + "directory", + Markup.Escape(directory))); + } commandState.RenderUser = () => { }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new @@ -541,11 +583,6 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire invalid = invalidCount, })); - if (invalidCount > 0) - { - return new ErrorCommandState(new CommandException("theme", summary)); - } - return commandState; } @@ -572,11 +609,10 @@ private CommandState RunOpen(CommandState commandState) targetPath, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message, ex)); + throw new CommandException("theme", message, ex); } - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-opened", "path", Markup.Escape(targetPath))); @@ -597,8 +633,7 @@ private CommandState RunEdit(CommandState commandState) if (string.IsNullOrWhiteSpace(requested)) { var message = MessageService.GetString("command-theme-edit-missing-name"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } // If the argument is itself a path to an existing file, edit it in place. @@ -633,11 +668,10 @@ private CommandState RunEdit(CommandState commandState) var message = MessageService.GetArgsString( "command-theme-edit-builtin-needs-force", "name", - Markup.Escape(registration.Name), + registration.Name, "path", - Markup.Escape(seedPath)); - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); - return new ErrorCommandState(new CommandException("theme", message)); + seedPath); + throw new CommandException("theme", message); } try @@ -652,12 +686,11 @@ private CommandState RunEdit(CommandState commandState) seedPath, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(failed)); - return new ErrorCommandState(new CommandException("theme", failed, ex)); + throw new CommandException("theme", failed, ex); } targetPath = seedPath; - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-edit-seeded", "name", Markup.Escape(registration.Name), @@ -668,18 +701,17 @@ private CommandState RunEdit(CommandState commandState) } else { - return ReportUnknownTheme(commandState, requested); + throw UnknownTheme(requested); } var editor = ExternalEditor.Resolve(null); if (editor is null) { var message = MessageService.GetString("command-theme-edit-no-editor"); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-theme-edit-launching", "path", targetPath!, @@ -708,8 +740,7 @@ private CommandState RunEdit(CommandState commandState) targetPath!, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message, ex)); + throw new CommandException("theme", message, ex); } if (exitCode != 0) @@ -720,8 +751,7 @@ private CommandState RunEdit(CommandState commandState) editor.DisplayName, "code", exitCode); - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); - return new ErrorCommandState(new CommandException("theme", message)); + throw new CommandException("theme", message); } // Reload registry so renamed/edited files are picked up, then apply the @@ -735,7 +765,7 @@ private CommandState RunEdit(CommandState commandState) // Re-scan the directory too so other files stay in sync with disk. registry.LoadFromDirectory(ThemeFile.DefaultUserThemesDirectory()); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-edit-applied", "name", Markup.Escape(result.Name), @@ -744,7 +774,7 @@ private CommandState RunEdit(CommandState commandState) foreach (var warning in result.Warnings) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -766,8 +796,7 @@ private CommandState RunEdit(CommandState commandState) targetPath!, "message", ex.Message); - AnsiConsole.MarkupLine(Theme.FormatError(message)); - return new ErrorCommandState(new CommandException("theme", message, ex)); + throw new CommandException("theme", message, ex); } } @@ -778,7 +807,7 @@ private CommandState RunReload(CommandState commandState) var directory = ThemeFile.DefaultUserThemesDirectory(); var loaded = registry.LoadFromDirectory(directory); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-reloaded", "count", loaded, @@ -786,7 +815,7 @@ private CommandState RunReload(CommandState commandState) Markup.Escape(directory))); foreach (var warning in registry.Warnings) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -800,16 +829,4 @@ private CommandState RunReload(CommandState commandState) })); return commandState; } - - private CommandState RunUnknownAction(CommandState commandState, string action) - { - var message = MessageService.GetArgsString( - "command-theme-unknown-action", - "action", - Markup.Escape(action), - "actions", - "current, list, show, use (alias: set), load, validate, save, edit, open, reload"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(new CommandException("theme", message)); - } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs index 2289c6e3..feacc280 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs @@ -154,7 +154,7 @@ void Row(string labelKey, string value) => Row("command-throughput-label-min", view.MinThroughput.Value.ToString(CultureInfo.InvariantCulture)); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); }, }; } @@ -215,8 +215,8 @@ void Row(string labelKey, string value) => Row("command-throughput-dry-run-label-current", DescribeMode(currentMode, current.Throughput, current.AutoscaleMaxThroughput)); Row("command-throughput-dry-run-label-planned", DescribeMode(plannedMode, plannedAutoscale ? null : plannedRu, plannedAutoscale ? plannedRu : null)); - AnsiConsole.Write(table); - ShellInterpreter.WriteLine(MessageService.GetString("command-throughput-dry-run-note")); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-throughput-dry-run-note")); }, }; } @@ -259,7 +259,7 @@ private static bool ConfirmWrite(ShellInterpreter shell, bool yes, string databa string resourceName = containerName ?? databaseName; string modeLabel = MessageService.GetString(isAutoscale ? "command-throughput-mode-autoscale" : "command-throughput-mode-manual"); string ruText = ru.ToString(CultureInfo.InvariantCulture); - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-throughput-confirm_summary", "resource", Markup.Escape(resourceName), "mode", modeLabel, "ru", ruText)); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString("command-throughput-confirm_summary", "resource", Markup.Escape(resourceName), "mode", modeLabel, "ru", ruText)); return ShellInterpreter.Confirm("command-throughput-confirm"); } @@ -318,7 +318,7 @@ private async Task ExecuteOnScopeAsync(ConnectedState state, Shell return new CommandState { Result = new ShellJson(cancelledDoc.RootElement.Clone()), - RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-throughput-cancelled")), + RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-throughput-cancelled")), }; } @@ -354,7 +354,7 @@ private async Task ExecuteOnScopeAsync(ConnectedState state, Shell var renderTable = built.RenderUser; built.RenderUser = () => { - ShellInterpreter.WriteLine(MessageService.GetString("command-throughput-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-throughput-updated")); renderTable?.Invoke(); }; return built; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs index e13c0863..f62cd31f 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs @@ -221,11 +221,11 @@ internal async Task ListAsync(Container container, ShellInterprete { if (rows.Count == 0) { - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-trigger-list-empty"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-trigger-list-empty"))); } else { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-title"))); var table = new Table(); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-column-id")))); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-trigger-list-column-type")))); @@ -241,7 +241,7 @@ internal async Task ListAsync(Container container, ShellInterprete Theme.FormatTableValue(row.BodyLength.ToString())); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } }; return commandState; @@ -282,7 +282,7 @@ internal async Task ExistsAsync(Container container, CommandState } commandState.Result = new ShellBool(exists); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( exists ? "command-trigger-exists-yes" : "command-trigger-exists-no", "name", name)); @@ -346,17 +346,17 @@ private async Task CreateAsync(Container container, ShellInterpret if (!string.IsNullOrWhiteSpace(edited)) { - AnsiConsole.Clear(); - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-trigger-create-preview", "name", name)); - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(edited); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.ClearScreen(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString("command-trigger-create-preview", "name", name)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, edited); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); } if (string.IsNullOrWhiteSpace(edited) || !ShellInterpreter.Confirm("command-trigger-create-confirm")) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "trigger", id = name, created = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-trigger-create-discarded", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-trigger-create-discarded", "name", name)); return commandState; } @@ -416,7 +416,7 @@ internal async Task WriteCreateAsync(Container container, CommandS triggerType = triggerType.ToString(), triggerOperation = triggerOperation.ToString(), })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( replaced ? "command-trigger-replaced" : "command-trigger-created", "name", name, @@ -434,7 +434,7 @@ internal async Task DeleteAsync(Container container, CommandState var response = await container.Scripts.DeleteTriggerAsync(name, cancellationToken: token); RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "trigger", id = name, deleted = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-trigger-deleted", "name", name, @@ -475,7 +475,7 @@ private async Task EditAsync(Container container, ShellInterpreter if (string.Equals(newBody, existingBody, StringComparison.Ordinal)) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "trigger", id = name, changed = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-trigger-edit-unchanged", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-trigger-edit-unchanged", "name", name)); return commandState; } @@ -490,7 +490,7 @@ private async Task EditAsync(Container container, ShellInterpreter RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "trigger", id = name, changed = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-trigger-replaced", "name", name, @@ -520,7 +520,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca { await File.WriteAllTextAsync(tempPath, initialBody, token); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-trigger-edit-launching", "name", name, @@ -551,7 +551,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.WriteLine(MessageService.GetString("command-trigger-edit-wait")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-trigger-edit-wait")); Console.ReadLine(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs index abf01d9b..fd2988b9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs @@ -201,7 +201,7 @@ private async Task ExecuteOnContainerAsync(ConnectedState state, s } var analyticalView = await CosmosResourceFacade.ReplaceAnalyticalTimeToLiveAsync(state, databaseName, containerName, targetTtl, token); - ShellInterpreter.WriteLine(MessageService.GetString("command-ttl-analytical-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-ttl-analytical-updated")); return BuildAnalyticalResult(containerName, analyticalView); } @@ -212,7 +212,7 @@ private async Task ExecuteOnContainerAsync(ConnectedState state, s } var view = await CosmosResourceFacade.ReplaceTimeToLiveAsync(state, databaseName, containerName, targetTtl, token); - ShellInterpreter.WriteLine(MessageService.GetString("command-ttl-updated")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-ttl-updated")); return BuildResult(containerName, view); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs index d5d2384d..94d4773e 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs @@ -172,11 +172,11 @@ internal async Task ListAsync(Container container, ShellInterprete { if (rows.Count == 0) { - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("command-udf-list-empty"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(MessageService.GetString("command-udf-list-empty"))); } else { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-title"))); var table = new Table(); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-column-id")))); table.AddColumn(new TableColumn(Theme.FormatSectionHeader(MessageService.GetString("command-udf-list-column-size"))).RightAligned()); @@ -188,7 +188,7 @@ internal async Task ListAsync(Container container, ShellInterprete Theme.FormatTableValue(row.BodyLength.ToString())); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); } }; return commandState; @@ -229,7 +229,7 @@ internal async Task ExistsAsync(Container container, CommandState } commandState.Result = new ShellBool(exists); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( exists ? "command-udf-exists-yes" : "command-udf-exists-no", "name", name)); @@ -285,17 +285,17 @@ private async Task CreateAsync(Container container, ShellInterpret if (!string.IsNullOrWhiteSpace(edited)) { - AnsiConsole.Clear(); - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-udf-create-preview", "name", name)); - ShellInterpreter.WriteLine(); - ShellInterpreter.WriteLine(edited); - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.ClearScreen(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString("command-udf-create-preview", "name", name)); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, edited); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); } if (string.IsNullOrWhiteSpace(edited) || !ShellInterpreter.Confirm("command-udf-create-confirm")) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, created = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-udf-create-discarded", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-udf-create-discarded", "name", name)); return commandState; } @@ -342,7 +342,7 @@ internal async Task WriteCreateAsync(Container container, CommandS } commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, created = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( replaced ? "command-udf-replaced" : "command-udf-created", "name", name, @@ -360,7 +360,7 @@ internal async Task DeleteAsync(Container container, CommandState var response = await container.Scripts.DeleteUserDefinedFunctionAsync(name, cancellationToken: token); RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, deleted = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-udf-deleted", "name", name, @@ -400,7 +400,7 @@ private async Task EditAsync(Container container, ShellInterpreter if (string.Equals(newBody, existingBody, StringComparison.Ordinal)) { commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, changed = false })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString("command-udf-edit-unchanged", "name", name)); + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-udf-edit-unchanged", "name", name)); return commandState; } @@ -409,7 +409,7 @@ private async Task EditAsync(Container container, ShellInterpreter RequestChargeContext.Record(response.RequestCharge); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "udf", id = name, changed = true })); - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + commandState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString( "command-udf-replaced", "name", name, @@ -439,7 +439,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca { await File.WriteAllTextAsync(tempPath, initialBody, token); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-udf-edit-launching", "name", name, @@ -470,7 +470,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.WriteLine(MessageService.GetString("command-udf-edit-wait")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-udf-edit-wait")); Console.ReadLine(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs index bf11ff49..e203bb7a 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs @@ -50,6 +50,11 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co { ArgumentNullException.ThrowIfNull(shell); + if (shell.IsMachineMode && this.Max is not > 0) + { + throw new CommandException("watch", MessageService.GetString("command-watch-error-machine_max_required")); + } + if (shell.State is not ConnectedState connectedState) { throw new NotConnectedException("watch"); @@ -166,7 +171,7 @@ private async Task WatchAsync(ShellInterpreter shell, Container co var collected = (max.HasValue || redirected) ? new List() : null; var count = 0; - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-watch-started", "container", Theme.ContainerNamePromt(container.Id))); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString("command-watch-started", "container", Theme.ContainerNamePromt(container.Id))); try { @@ -195,9 +200,9 @@ private async Task WatchAsync(ShellInterpreter shell, Container co var limitReached = false; foreach (var element in documents) { - if (!redirected) + if (!redirected && (collected == null || !shell.IsMachineMode)) { - AnsiConsole.MarkupLine(JsonOutputHighlighter.BuildMarkup(element)); + shell.Output.MarkupLine(ShellMessageKind.Result, JsonOutputHighlighter.BuildMarkup(element)); } collected?.Add(element); @@ -221,7 +226,7 @@ private async Task WatchAsync(ShellInterpreter shell, Container co // Ctrl+C: stop tailing and report what was seen so far. } - AnsiConsole.MarkupLine(MessageService.GetArgsString("command-watch-stopped", "count", count.ToString())); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString("command-watch-stopped", "count", count.ToString())); var result = new CommandState(); result.SetFormat(this.OutputFormat); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs index 1a50b55f..602d7d47 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WhoamiCommand.cs @@ -122,7 +122,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co private static void RenderTable(string credentialType, Dictionary? identity, string? note) { - AnsiConsole.MarkupLine(Theme.FormatSectionHeader(MessageService.GetString("command-whoami-title"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatSectionHeader(MessageService.GetString("command-whoami-title"))); var table = new Table(); table.AddColumns(string.Empty, string.Empty); @@ -141,11 +141,11 @@ private static void RenderTable(string credentialType, Dictionary internal static class OutputPolicy { + // Command code presents results only for interactive users. Machine-mode results are + // emitted once by the interpreter through ShellOutput.WriteResult. + internal static bool ShouldPresent(ShellMessageKind kind, bool quiet, bool machineMode) + => kind switch + { + ShellMessageKind.Information or ShellMessageKind.Progress => !quiet && !machineMode, + ShellMessageKind.Result => !machineMode, + ShellMessageKind.Warning or ShellMessageKind.Error or ShellMessageKind.RequiredInstruction => true, + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + internal static bool UseStandardError(ShellMessageKind kind, bool machineMode) + => machineMode && kind is ShellMessageKind.Warning or ShellMessageKind.Error or ShellMessageKind.RequiredInstruction; + /// /// Determines whether the given format is a structured, machine-consumable format. /// and are structured diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 638f8140..29964f9d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -33,11 +33,6 @@ public partial class ShellInterpreter : IDisposable private const string SessionRequestChargeWarningThresholdVariable = "sessionRequestChargeWarningThreshold"; - // Declared before Instance: static initializers run in order and the constructor reads history. - private static readonly object HistoryFileLock = new(); - - internal static readonly ShellInterpreter Instance = new(); - private const int MAXHISTORYITEMS = 60; private const int HistoryFileOpenRetryCount = 10; @@ -53,6 +48,10 @@ public partial class ShellInterpreter : IDisposable // user command that just happens to start with the prefix string. private const string EncodedHistoryLineMarker = "E:"; + private static readonly object HistoryFileLock = new(); + + internal static readonly ShellInterpreter Instance = new(); + private static readonly TimeSpan HistoryFileOpenRetryDelay = TimeSpan.FromMilliseconds(25); private static readonly TimeSpan LocalEmulatorOperationTimeout = TimeSpan.FromSeconds(10); @@ -110,6 +109,9 @@ public partial class ShellInterpreter : IDisposable internal ShellInterpreter(string? configPath = null) { this.State = new DisconnectedState(); + this.Output = new ShellOutput( + () => this.Options?.Quiet == true, + () => this.IsMachineMode); // editor.KeyBindings.Add(ConsoleKey.Escape); // TODO: Support selection commands? @@ -154,6 +156,8 @@ internal ShellInterpreter(string? configPath = null) internal event Action? LocationChanged; + internal ShellOutput Output { get; } + /// /// Gets the line editor instance used by the shell, or null if not available. /// @@ -406,83 +410,58 @@ internal OutputFormat DefaultOutputFormat } /// - /// Create a new instance of the class. + /// Creates a new shell interpreter instance. /// - /// A new instance of the class. + /// A new shell interpreter. public static ShellInterpreter CreateInstance() { return new ShellInterpreter(); } /// - /// Writes the specified message to the standard output stream, using the specified format parameters. + /// Writes an informational message through the shell output policy, using the specified format parameters. /// /// The message to write. /// An array of objects to format. public static void WriteLine(string message, params object[] par) { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.WriteLine(message, par); + Instance.Output.WriteLine(ShellMessageKind.Information, message, par); } /// - /// Writes the specified message to the standard output stream. + /// Writes an informational message through the shell output policy. /// /// The message to write. public static void WriteLine(string message) { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.WriteLine(message); + Instance.Output.WriteLine(ShellMessageKind.Information, message); } /// - /// Writes an empty line to the standard output stream. + /// Writes an informational empty line through the shell output policy. /// public static void WriteLine() { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.WriteLine(); + Instance.Output.WriteLine(ShellMessageKind.Information); } /// - /// Writes the specified message to the standard output stream, using the specified format parameters. + /// Writes an informational message through the shell output policy, using the specified format parameters. /// /// The message to write. /// An array of objects to format. public static void Write(string message, params object[] par) { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.Write(message, par); + Instance.Output.Write(ShellMessageKind.Information, message, par); } /// - /// Writes the specified message to the standard output stream. + /// Writes an informational message through the shell output policy. /// /// The message to write. public static void Write(string message) { - if (Instance?.Options?.Quiet == true) - { - return; - } - - Console.Write(message); + Instance.Output.Write(ShellMessageKind.Information, message); } /// @@ -503,7 +482,7 @@ public static bool Confirm(string message) { while (true) { - Console.Write($"{MessageService.GetString(message)} ({yes}/{no})?"); + Instance.Output.Write(ShellMessageKind.RequiredInstruction, $"{MessageService.GetString(message)} ({yes}/{no})?"); ConsoleKeyInfo key; try @@ -514,25 +493,25 @@ public static bool Confirm(string message) { // No interactive console available (e.g. redirected input). Treat as // a declined prompt rather than throwing. - WriteLine(); + Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); return false; } if (key.Key == ConsoleKey.C && key.Modifiers.HasFlag(ConsoleModifiers.Control)) { - WriteLine("^C"); + Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, "^C"); return false; } if (key.Key == ConsoleKey.Escape) { - WriteLine(); + Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); return false; } // intercept:true suppresses the echo, so mirror the keystroke ourselves. - Console.Write(key.KeyChar); - WriteLine(); + Instance.Output.Write(ShellMessageKind.RequiredInstruction, key.KeyChar.ToString()); + Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction); if (char.ToUpper(key.KeyChar) == yes) { @@ -868,7 +847,7 @@ internal static string GetRepositoryUrl(Assembly assembly) internal static void ReportError(string message, params object[] par) { - AnsiConsole.MarkupLine(Theme.FormatError(message), par); + Instance.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatError(message), par); } internal ShellObject GetVariable(string name) @@ -902,19 +881,13 @@ internal ShellObject GetVariable(string name) internal void PrintVersion(CommandState? commandState) { - var isQuiet = this.Options?.Quiet == true; var version = GetDisplayVersion(typeof(VersionCommand).Assembly); var port = this.McpPort; var repoUrl = GetRepositoryUrl(typeof(VersionCommand).Assembly); if (commandState == null) { - // Startup banner: render immediately for interactive users. - if (!isQuiet) - { - RenderVersionBanner(version, port, repoUrl); - } - + RenderVersionBanner(this.Output, ShellMessageKind.Information, version, port, repoUrl); return; } @@ -929,34 +902,38 @@ internal void PrintVersion(CommandState? commandState) var jsonElement = System.Text.Json.JsonSerializer.SerializeToElement(json); commandState.Result = new ShellJson(jsonElement); - commandState.RenderUser = () => RenderVersionBanner(version, port, repoUrl); + commandState.RenderUser = () => RenderVersionBanner(this.Output, ShellMessageKind.Result, version, port, repoUrl); } - private static void RenderVersionBanner(string version, int? port, string repoUrl) + private static void RenderVersionBanner(ShellOutput output, ShellMessageKind kind, string version, int? port, string repoUrl) { var versionString = MessageService.GetArgsString("command-version", "version", version); - AnsiConsole.MarkupLine(versionString); + output.MarkupLine(kind, versionString); if (port != null) { var mcpPortString = MessageService.GetArgsString("command-version-mcp", "mcp_port", port?.ToString() ?? string.Empty); - AnsiConsole.MarkupLine(Theme.FormatWarning(mcpPortString)); + output.MarkupLine(kind, Theme.FormatWarning(mcpPortString)); } else { - AnsiConsole.MarkupLine(MessageService.GetString("command-version-mcp-off")); + output.MarkupLine(kind, MessageService.GetString("command-version-mcp-off")); } if (!string.IsNullOrEmpty(repoUrl)) { var repoString = MessageService.GetArgsString("command-version-repo", "url", repoUrl); - AnsiConsole.MarkupLine(repoString); + output.MarkupLine(kind, repoString); } } internal void ShowWelcome() { - WelcomeScreen.WriteTo(Console.Out); + this.Output.Write(ShellMessageKind.Information, WelcomeScreen.Text + "\u001b[0m"); + if (!WelcomeScreen.Text.EndsWith('\n')) + { + this.Output.WriteLine(ShellMessageKind.Information); + } } internal bool ShowWelcomeOnFirstRun() @@ -1444,7 +1421,7 @@ internal async Task ConnectAsync(string connectionString, string? loginHint = nu { DeviceCodeCallback = (code, cancellationToken) => { - ShellInterpreter.WriteLine(code.Message); + this.Output.WriteLine(ShellMessageKind.RequiredInstruction, code.Message); return Task.CompletedTask; }, }; @@ -1929,18 +1906,23 @@ internal void PrintCommand(string cmdString) System.Diagnostics.Debug.WriteLine(ex); } + if (this.Options?.Quiet == true) + { + return; + } + // Echoing and the line editor both need an ANSI terminal, which an MCP host may not // provide. Neither may fail the command being announced. try { var txt = ((IHighlighter)Instance).BuildHighlightedText(cmdString); - AnsiConsole.Write(txt); - AnsiConsole.WriteLine(); // Ensure the next output starts on a new line + this.Output.Render(ShellMessageKind.Information, txt); + this.Output.WriteLine(ShellMessageKind.Information); // Ensure the next output starts on a new line this.Editor?.History.Add(cmdString); } catch (NotSupportedException) { - Console.Out.WriteLine(cmdString); + this.Output.WriteLine(ShellMessageKind.Information, cmdString); } } @@ -2018,7 +2000,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false var element = (JsonElement?)state.Result.ConvertShellObject(Parser.DataType.Json); if (element.HasValue) { - AnsiConsole.MarkupLine(JsonOutputHighlighter.BuildMarkup(element.Value)); + this.Output.MarkupLine(ShellMessageKind.Result, JsonOutputHighlighter.BuildMarkup(element.Value)); return state; } } @@ -2037,7 +2019,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false && output != null && state.Result is ShellText { Highlighter: { } highlighter }) { - AnsiConsole.MarkupLine(highlighter(output)); + this.Output.MarkupLine(ShellMessageKind.Result, highlighter(output)); return state; } } @@ -2046,7 +2028,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false { if (!redirected) { - Console.Out.WriteLine(output); + this.Output.WriteResult(output); } else { @@ -2058,7 +2040,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false { if (this.Options?.Verbose == true) { - AnsiConsole.WriteException(e); + this.Output.WriteException(ShellMessageKind.Error, e); return new ErrorCommandState(e); } @@ -2078,7 +2060,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false } else if (!string.IsNullOrEmpty(canceled)) { - AnsiConsole.MarkupLine(Theme.FormatWarning(canceled)); + this.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(canceled)); } return new ErrorCommandState(e); @@ -2091,16 +2073,16 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false else { var prefix = MessageService.GetString("runtime-error-prefix") ?? "error"; - AnsiConsole.MarkupLine($"{Theme.FormatError(prefix + ":")} {Markup.Escape(e.Message)}"); + this.Output.MarkupLine(ShellMessageKind.Error, $"{Theme.FormatError(prefix + ":")} {Markup.Escape(e.Message)}"); if (e is IShellExceptionWithHint hinted && !string.IsNullOrEmpty(hinted.Hint)) { - AnsiConsole.MarkupLine(Markup.Escape(hinted.Hint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(hinted.Hint)); } var inner = e.InnerException; while (inner != null) { - AnsiConsole.MarkupLine($" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); + this.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); inner = inner.InnerException; } } @@ -2236,7 +2218,7 @@ private void PrintSessionRequestChargeWarning(double requestCharge, double warni if (this.IsMachineMode) { - Console.Error.WriteLine(JsonSerializer.Serialize(new Dictionary + this.Output.WriteLine(ShellMessageKind.Warning, JsonSerializer.Serialize(new Dictionary { ["status"] = "warning", ["warning"] = message, @@ -2244,7 +2226,7 @@ private void PrintSessionRequestChargeWarning(double requestCharge, double warni } else { - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); + this.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(message)); } } @@ -2274,7 +2256,7 @@ protected virtual void Dispose(bool disposing) private static string? PromptFallback() { - Console.Write(CosmosShellPrompt.PromptMarker + " "); + Instance.Output.Write(ShellMessageKind.RequiredInstruction, CosmosShellPrompt.PromptMarker + " "); return Console.ReadLine(); } @@ -2341,7 +2323,7 @@ internal static CosmosClientOptions CreateClientOptions(ConnectionMode requested } catch (Exception e) { - Console.Error.WriteLine(e.Message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, e.Message); return null; } } @@ -2863,7 +2845,7 @@ private void WriteMachineError(string errorMessage, ShellObject? result = null) } else { - Console.Error.WriteLine(json); + this.Output.WriteLine(ShellMessageKind.Error, json); } } @@ -2888,7 +2870,7 @@ internal static void WriteConnectionError(Exception exception, bool verbose) var cosmos = FindException(exception); if (cosmos != null) { - AnsiConsole.MarkupLine(Theme.FormatError(MessageService.GetArgsString( + Instance.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatError(MessageService.GetArgsString( "shell-connect-error-cosmos-detail", "status", ((int)cosmos.StatusCode).ToString(CultureInfo.InvariantCulture), @@ -2904,20 +2886,20 @@ internal static void WriteConnectionError(Exception exception, bool verbose) // per-credential failure reasons from DefaultAzureCredential's // AuthenticationFailedException and the Cosmos error body/diagnostics // from CosmosException.ToString(). - AnsiConsole.WriteLine(exception.ToString()); + Instance.Output.RenderLine(ShellMessageKind.Error, exception.ToString()); return; } - AnsiConsole.MarkupLine(Theme.FormatError(exception.Message)); + Instance.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatError(exception.Message)); var inner = exception.InnerException; while (inner != null) { - AnsiConsole.MarkupLine($" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); + Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2192")} {Markup.Escape(inner.Message)}"); inner = inner.InnerException; } - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetString("shell-connect-verbose-hint"))); + Instance.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatMuted(MessageService.GetString("shell-connect-verbose-hint"))); } private void ReportExecutionError(Exception e, string? sourceText = null) @@ -2982,20 +2964,20 @@ private void ReportExecutionError(Exception e, string? sourceText = null) { if (!string.IsNullOrEmpty(prefix)) { - AnsiConsole.MarkupLine(Markup.Escape(prefix.TrimEnd())); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(prefix.TrimEnd())); } - AnsiConsole.WriteException(e, new ExceptionSettings + this.Output.WriteException(ShellMessageKind.Error, e, new ExceptionSettings { Format = ExceptionFormats.ShortenPaths, }); } else { - AnsiConsole.MarkupLine(prefix + Theme.FormatError(e.Message)); + this.Output.MarkupLine(ShellMessageKind.Error, prefix + Theme.FormatError(e.Message)); if (!string.IsNullOrEmpty(hint)) { - AnsiConsole.MarkupLine(Markup.Escape(hint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(hint)); } if (showInner) @@ -3003,7 +2985,7 @@ private void ReportExecutionError(Exception e, string? sourceText = null) var inner = e.InnerException; while (inner != null) { - AnsiConsole.MarkupLine($" {Theme.FormatError("->")} {Markup.Escape(inner.Message)}"); + this.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("->")} {Markup.Escape(inner.Message)}"); inner = inner.InnerException; } } @@ -3038,7 +3020,7 @@ private void ReportCommandNotFoundError(CommandNotFoundException e, string sourc } else { - AnsiConsole.MarkupLine(Markup.Escape(e.Hint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(e.Hint)); } } @@ -3058,7 +3040,7 @@ private void ReportCommandNotFoundError(CommandNotFoundException e, string sourc } else { - AnsiConsole.MarkupLine(Markup.Escape(csHint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(csHint)); } } } @@ -3119,16 +3101,16 @@ private void ReportPositionalError(PositionalException pe) } else { - AnsiConsole.MarkupLine($"{Markup.Escape($"{pe.FileName}:{pe.Line}:{pe.Column}:")} {Theme.FormatError(MessageService.GetString("runtime-error-prefix") + ":")} {Markup.Escape(pe.Message)}"); + this.Output.MarkupLine(ShellMessageKind.Error, $"{Markup.Escape($"{pe.FileName}:{pe.Line}:{pe.Column}:")} {Theme.FormatError(MessageService.GetString("runtime-error-prefix") + ":")} {Markup.Escape(pe.Message)}"); if (pe.LineText != null) { - AnsiConsole.MarkupLine(" " + Theme.FormatMuted(pe.LineText)); - AnsiConsole.MarkupLine(" " + Theme.FormatError(new string(' ', Math.Max(0, pe.Column - 1)) + "^")); + this.Output.MarkupLine(ShellMessageKind.Error, " " + Theme.FormatMuted(pe.LineText)); + this.Output.MarkupLine(ShellMessageKind.Error, " " + Theme.FormatError(new string(' ', Math.Max(0, pe.Column - 1)) + "^")); } foreach (var frame in callTrace) { - AnsiConsole.MarkupLine(Theme.FormatMuted(frame)); + this.Output.MarkupLine(ShellMessageKind.Error, Theme.FormatMuted(frame)); } } } @@ -3360,16 +3342,16 @@ private void AppendSourceCaretDiagnostic( if (hasOrigin) { var location = $"{origin}:{lineNumber}:{rendered.SourceColumn}:"; - AnsiConsole.MarkupLine($"{Theme.FormatMuted(location)} {FormatLevel(levelPrefix + ":")} {m}"); + this.Output.MarkupLine(isWarning ? ShellMessageKind.Warning : ShellMessageKind.Error, $"{Theme.FormatMuted(location)} {FormatLevel(levelPrefix + ":")} {m}"); } else { - AnsiConsole.MarkupLine($"{FormatLevel(levelPrefix + ":")} {m} {Theme.FormatMuted($"({lineNumber}:{rendered.SourceColumn})")}"); + this.Output.MarkupLine(isWarning ? ShellMessageKind.Warning : ShellMessageKind.Error, $"{FormatLevel(levelPrefix + ":")} {m} {Theme.FormatMuted($"({lineNumber}:{rendered.SourceColumn})")}"); } var gutter = $" > {lineNumber} | "; - AnsiConsole.MarkupLine($"{Theme.FormatMuted(gutter)}{Markup.Escape(rendered.Display)}"); - AnsiConsole.MarkupLine($"{Theme.FormatMuted(new string(' ', gutter.Length) + rendered.CaretLeader)}{FormatLevel(rendered.CaretPad + rendered.CaretMarker)}"); + this.Output.MarkupLine(isWarning ? ShellMessageKind.Warning : ShellMessageKind.Error, $"{Theme.FormatMuted(gutter)}{Markup.Escape(rendered.Display)}"); + this.Output.MarkupLine(isWarning ? ShellMessageKind.Warning : ShellMessageKind.Error, $"{Theme.FormatMuted(new string(' ', gutter.Length) + rendered.CaretLeader)}{FormatLevel(rendered.CaretPad + rendered.CaretMarker)}"); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs new file mode 100644 index 00000000..72ffb1c8 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs @@ -0,0 +1,218 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.Core; + +using Spectre.Console; +using Spectre.Console.Rendering; + +internal enum ShellMessageKind +{ + Information, + Progress, + Warning, + Error, + RequiredInstruction, + Result, +} + +internal sealed class ShellOutput( + Func quiet, + Func machineMode, + Func? standardOutput = null, + Func? standardError = null, + Func? console = null) +{ + private readonly object consoleCacheLock = new(); + + private TextWriter? cachedPlainWriter; + + private IAnsiConsole? cachedPlainConsole; + + // Early failures and terminal initialization diagnostics always use stderr. + internal static ShellOutput StandardError { get; } = new(() => false, () => true); + + /// + /// Clears the interactive terminal. Machine mode has no screen to clear, + /// and emitting terminal control sequences there would corrupt their output streams. + /// + internal void ClearScreen() + { + if (!machineMode()) + { + (console?.Invoke() ?? AnsiConsole.Console).Clear(); + } + } + + /// + /// Writes the interpreter's final, already formatted command result. Unlike + /// presentation, this is the machine-mode + /// stdout contract. + /// + internal void WriteResult(string text) + { + (standardOutput?.Invoke() ?? Console.Out).WriteLine(text); + } + + internal void WriteLine(ShellMessageKind kind, string message = "", params object[] args) + { + if (this.ShouldPresent(kind)) + { + var writer = this.GetWriter(kind); + if (args.Length == 0) + { + writer.WriteLine(message); + } + else + { + writer.WriteLine(message, args); + } + } + } + + internal void WriteLine(ShellMessageKind kind, string message) + { + if (this.ShouldPresent(kind)) + { + this.GetWriter(kind).WriteLine(message); + } + } + + internal void Write(ShellMessageKind kind, string message, params object[] args) + { + if (this.ShouldPresent(kind)) + { + var writer = this.GetWriter(kind); + if (args.Length == 0) + { + writer.Write(message); + } + else + { + writer.Write(message, args); + } + } + } + + internal void Write(ShellMessageKind kind, string message) + { + if (this.ShouldPresent(kind)) + { + this.GetWriter(kind).Write(message); + } + } + + internal void MarkupLine(ShellMessageKind kind, string markup, params object[] args) + { + if (this.ShouldPresent(kind)) + { + var target = this.GetConsole(kind); + if (args.Length == 0) + { + target.MarkupLine(markup); + } + else + { + target.MarkupLine(markup, args); + } + } + } + + internal void MarkupLine(ShellMessageKind kind, string markup) + { + if (this.ShouldPresent(kind)) + { + this.GetConsole(kind).MarkupLine(markup); + } + } + + internal void Markup(ShellMessageKind kind, string markup, params object[] args) + { + if (this.ShouldPresent(kind)) + { + var target = this.GetConsole(kind); + if (args.Length == 0) + { + target.Markup(markup); + } + else + { + target.Markup(markup, args); + } + } + } + + internal void Markup(ShellMessageKind kind, string markup) + { + if (this.ShouldPresent(kind)) + { + this.GetConsole(kind).Markup(markup); + } + } + + internal void RenderLine(ShellMessageKind kind, string text = "") + { + if (this.ShouldPresent(kind)) + { + this.GetConsole(kind).WriteLine(text); + } + } + + internal void Render(ShellMessageKind kind, IRenderable renderable) + { + if (this.ShouldPresent(kind)) + { + this.GetConsole(kind).Write(renderable); + } + } + + internal void WriteException(ShellMessageKind kind, Exception exception, ExceptionSettings? settings = null) + { + if (this.ShouldPresent(kind)) + { + if (machineMode()) + { + this.GetWriter(kind).WriteLine(exception); + } + else + { + this.GetConsole(kind).WriteException(exception, settings ?? new ExceptionSettings()); + } + } + } + + private bool ShouldPresent(ShellMessageKind kind) + => OutputPolicy.ShouldPresent(kind, quiet(), machineMode()); + + private TextWriter GetWriter(ShellMessageKind kind) + => OutputPolicy.UseStandardError(kind, machineMode()) + ? standardError?.Invoke() ?? Console.Error + : standardOutput?.Invoke() ?? Console.Out; + + private IAnsiConsole GetConsole(ShellMessageKind kind) + { + if (!machineMode() && !OutputPolicy.UseStandardError(kind, machineMode())) + { + return console?.Invoke() ?? AnsiConsole.Console; + } + + var writer = this.GetWriter(kind); + lock (this.consoleCacheLock) + { + if (!ReferenceEquals(writer, this.cachedPlainWriter) || this.cachedPlainConsole is null) + { + this.cachedPlainWriter = writer; + this.cachedPlainConsole = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, + Out = new AnsiConsoleOutput(writer), + }); + } + + return this.cachedPlainConsole; + } + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs index fcd4426b..64a269b0 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs @@ -4,13 +4,13 @@ namespace Azure.Data.Cosmos.Shell.KeyBindings; +using Azure.Data.Cosmos.Shell.Core; using RadLine; -using Spectre.Console; internal class ClearScreenCommand : LineEditorCommand { public override void Execute(LineEditorContext context) { - AnsiConsole.Clear(); + ShellInterpreter.Instance.Output.ClearScreen(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs index 18ea7e21..2c784b54 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs @@ -119,12 +119,14 @@ private static ReverseHistorySearchResult FindInitialMatch(IReadOnlyList return isForwardSearch ? ReverseHistorySearch.FindInitialForwardMatch(history, query) : ReverseHistorySearch.FindInitialMatch(history, query); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CZ0003:Direct console output", Justification = "Line-editor drawing runs only inside the interactive RadLine editor.")] private static void Render(string query, ReverseHistorySearchResult result, bool isForwardSearch, int? promptRow, ShellInterpreter shell) { ClearLine(promptRow); AnsiConsole.Markup(ReverseHistorySearch.FormatSearchPromptMarkup(query, result.Match, result.HasMatch, isForwardSearch, shell, TryGetLineWidth())); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CZ0003:Direct console output", Justification = "Cursor control for the interactive RadLine editor, not user-facing output.")] private static void ClearLine(int? promptRow) { try diff --git a/CosmosDBShell/GlobalSuppressions.cs b/CosmosDBShell/GlobalSuppressions.cs index 0e73943e..5b02218d 100644 --- a/CosmosDBShell/GlobalSuppressions.cs +++ b/CosmosDBShell/GlobalSuppressions.cs @@ -8,11 +8,6 @@ // a specific target and scoped to a namespace, type, member, etc. using System.Diagnostics.CodeAnalysis; -[assembly: SuppressMessage("Naming", "CZ0002:WriteLine", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.WriteLine(System.String,System.Object[])")] -[assembly: SuppressMessage("Naming", "CZ0002:WriteLine", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.WriteLine(System.String)")] -[assembly: SuppressMessage("Naming", "CZ0002:WriteLine", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.WriteLine")] -[assembly: SuppressMessage("Naming", "CZ0002:Write", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.Write(System.String,System.Object[])")] -[assembly: SuppressMessage("Naming", "CZ0002:Write", Justification = "Intentional I need to use Console.WriteLine at one point.", Scope = "member", Target = "~M:Azure.Data.Cosmos.Shell.Core.ShellInterpreter.Write(System.String)")] [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA0001:XML comment analysis is disabled due to project configuration", Justification = "CLI tool does not ship XML documentation.")] [assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1600:Elements should be documented", Justification = "No API, but required public for the localization system.", Scope = "type", Target = "~T:Azure.Data.Cosmos.Shell.Util.LocalizableSentenceBuilder")] [assembly: SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1202:Elements should be ordered by access", Justification = "Internal members exposed only for testing are kept next to their private callers.")] diff --git a/CosmosDBShell/Program.cs b/CosmosDBShell/Program.cs index faf5fc6b..f5a7c390 100644 --- a/CosmosDBShell/Program.cs +++ b/CosmosDBShell/Program.cs @@ -50,7 +50,7 @@ public static async Task Main(string[] args) var preCommandArgs = TakePreCommandArgs(args); if (preCommandArgs.Any(a => a is "--help" or "-h" or "-?" or "/?" or "/h")) { - ShellInterpreter.WriteLine(BuildHelpText()); + ShellInterpreter.Instance.Output.WriteResult(BuildHelpText()); return; } @@ -89,10 +89,10 @@ public static async Task Main(string[] args) { foreach (var error in parseResult.Errors) { - ShellInterpreter.WriteLine(error.Message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, error.Message); } - ShellInterpreter.WriteLine(BuildHelpText()); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, BuildHelpText()); } Environment.ExitCode = ShellExitCode.UsageError; @@ -174,7 +174,7 @@ public static async Task Main(string[] args) } else { - ShellInterpreter.WriteLine(msg); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, msg); } return; @@ -206,7 +206,7 @@ public static async Task Main(string[] args) } else { - ShellInterpreter.WriteLine(MessageService.GetString("error-mutually-exclusive-options")); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, MessageService.GetString("error-mutually-exclusive-options")); } return; @@ -237,7 +237,7 @@ void WriteStartupError(string message) return; } - AnsiConsole.WriteLine(message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, message); } if (o.Container != null && o.Database == null) @@ -289,22 +289,22 @@ void WriteStartupError(string message) _ => ColorSystem.NoColors, }; - ApplyTheme(o.Theme, startupMachineMode); + ShellInterpreter.Instance.Options = o; + ApplyTheme(o.Theme); if (startupMachineMode) { - // Keep machine-mode stdout deterministic even if a command - // accidentally writes via AnsiConsole instead of command state output. + // Safety net for dependency writes through the global Spectre console: + // machine-mode stdout carries only interpreter results. AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings { Ansi = AnsiSupport.No, ColorSystem = ColorSystemSupport.NoColors, + Interactive = InteractionSupport.No, Out = new AnsiConsoleOutput(TextWriter.Null), }); } - ShellInterpreter.Instance.Options = o; - // Enable diagnostic logging before connecting so the startup --connect // event is captured in the log. if (o.EnableDiagnostics) @@ -519,7 +519,7 @@ private static void WriteVersionHeading() var heading = string.IsNullOrEmpty(commit) ? $"{product} {version}" : $"{product} {version} ({commit})"; - ShellInterpreter.WriteLine(heading); + ShellInterpreter.Instance.Output.WriteResult(heading); } private static async Task StopHostAsync(IHost? host, Task? hostTask) @@ -771,7 +771,7 @@ private static (RootCommand Command, OptionMap Map) BuildRootCommand() private static void WriteErrorLine(string message) { - Console.Error.WriteLine(message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, message); } private static string BuildHelpText() @@ -847,9 +847,9 @@ private static string BuildHelpText() /// Resolves the requested theme profile and applies it via . /// Resolution order: explicit --theme flag, then COSMOSDB_SHELL_THEME /// environment variable, then the built-in default. Unknown names emit a warning - /// to standard output and fall back to the default profile. + /// and fall back to the default profile. /// - private static void ApplyTheme(string? themeFromCli, bool suppressStartupWarnings) + private static void ApplyTheme(string? themeFromCli) { // Always scan the user themes directory so file-loaded themes are visible // to --theme, the COSMOSDB_SHELL_THEME env var, and the in-shell `theme` @@ -859,10 +859,7 @@ private static void ApplyTheme(string? themeFromCli, bool suppressStartupWarning registry.LoadFromDirectory(ThemeFile.DefaultUserThemesDirectory()); foreach (var warning in registry.Warnings) { - if (!suppressStartupWarnings) - { - ShellInterpreter.WriteLine(warning); - } + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, warning); } var requested = !string.IsNullOrWhiteSpace(themeFromCli) @@ -876,15 +873,12 @@ private static void ApplyTheme(string? themeFromCli, bool suppressStartupWarning if (!ThemeProfiles.TryGet(requested, out var profile)) { - if (!suppressStartupWarnings) - { - ShellInterpreter.WriteLine(MessageService.GetArgsString( - "warning-unknown-theme", - "name", - requested, - "themes", - string.Join(", ", registry.All.Keys))); - } + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString( + "warning-unknown-theme", + "name", + requested, + "themes", + string.Join(", ", registry.All.Keys))); } Theme.Apply(profile); diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 159abb15..dfb52f16 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -212,7 +212,7 @@ help-examples = Examples: help-examples-heading = Examples help-aliases = Aliases: help-OutputFormat = The output format to use (user, json, table, csv). -help-Quiet = Suppresses standard informational output. +help-Quiet = Suppress informational messages, progress, banners, and command echoes. Preserve results, warnings, errors, and required authentication instructions. command-help-description = Shows help information for commands command-welcome-description = Displays the welcome screen @@ -803,7 +803,7 @@ command-results-limit_reached = command-watch-description = Tail the change feed of a container, streaming new and modified items as they arrive. command-watch-description-from-beginning = Start from the beginning of the change feed instead of from now. command-watch-description-partition-key = Scope the change feed to a single partition key value. -command-watch-description-max = Stop after receiving this many changes. Omit, or use 0 or a negative value, to follow until Ctrl+C. +command-watch-description-max = Stop after receiving this many changes. Required and positive in machine mode. In interactive mode, omit or use 0 or a negative value to follow until Ctrl+C. command-watch-description-interval = Seconds to wait between change feed polls once caught up. Defaults to 1; values below 0.1 are clamped. command-watch-description-format = { command-query-description-format } command-watch-description-database = The database containing the container to watch. @@ -812,6 +812,7 @@ command-watch-started = Watching changes in { $container }. Press Ctrl+C to stop command-watch-stopped = Stopped watching after { $count } changes. command-watch-error-request_failed = Change feed request failed with status code { $statusCode } ({ $status }). command-watch-error-invalid_interval = Interval value '{ $interval }' is invalid. Specify a finite number of seconds. +command-watch-error-machine_max_required = Machine-mode watch requires a positive --max value. Use interactive mode to follow changes without a limit. command-jq-description = Commandline JSON processor command-jq-description-args = Arguments for the jq command diff --git a/docs/commands.md b/docs/commands.md index 5a1bfb1b..57b6cc5a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -365,6 +365,8 @@ theme reload `theme validate` parses a TOML file and reports warnings without registering it or switching the active theme. When the argument is a directory it validates every `*.toml` file in that directory and prints a per-file summary. With no argument it scans the user themes directory (`~/.cosmosdbshell/themes`). The validator collects every issue in a single pass so that multiple typos can be fixed at once, and suggests the closest valid token when an unknown color or modifier is used. It also warns on bracket cycles that have only one color or contain duplicates. Pass `--strict` to fail when any warnings are present. Color values must be empty or one ANSI 16 color name. Style values may combine modifiers with at most one ANSI 16 color. +In machine mode (including `--quiet`), successful validation keeps warnings on stderr and the structured result on stdout. Directory scans defer warnings until every file has been checked; failed scans emit only the structured error on stderr. + ## Data Operations ### query @@ -802,6 +804,8 @@ Options: By default `watch` starts from now and follows the container, printing each change as highlighted JSON until you press Ctrl+C. Use `--from-beginning` to replay existing items first, `--partition-key` to scope the feed to one partition, and `--max` to stop automatically after a number of changes. Use `--interval` to change how long the shell waits between polls once it has caught up (default 1 second; values below 0.1 are clamped to avoid hammering the container). The change feed surfaces creates and updates (not deletes). This command is interactive and streaming, so it is not exposed over MCP. +Machine mode (`--quiet`, `--output json`, `--output csv`, or default execute-and-quit output) requires a positive `--max`. Changes are collected and emitted as the final result rather than streamed to stdout. An unbounded machine-mode watch fails before opening the change feed; interactive mode still supports following until Ctrl+C. + ```bash watch watch --from-beginning diff --git a/docs/navigation.md b/docs/navigation.md index dd9f2d8e..55368246 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -270,7 +270,7 @@ Start the shell with options to customize behavior: | Option | Description | | ------ | ----------- | | `--output ` | Output format for command results: `user` (default interactive view), `json`, `table`, or `csv`. Alias: `-o`. Selecting `json` or `csv` enables machine mode. Falls back to `COSMOSDB_SHELL_FORMAT`. | -| `--quiet` | Suppress standard informational output (banners, connection logs). Enables machine mode. Alias: `-q` | +| `--quiet` | Suppress informational messages, progress, banners, and command echoes. Preserve results, warnings, errors, and required authentication instructions. Enables machine mode. Alias: `-q` | | `-c ` | Execute command and exit. Everything after `-c` is taken as the command, so app-level options must come before `-c`. Windows-style `/c` is also accepted. | | `-k ` | Execute command and stay in shell. Everything after `-k` is taken as the command, so app-level options must come before `-k`. Windows-style `/k` is also accepted. | | `--connect ` | Connect with this connection string or endpoint on startup | @@ -302,10 +302,16 @@ output. **Machine mode** is entered when any of the following is true: In machine mode the shell disables ANSI colors, suppresses connection/informational banners, emits command results as the selected structured format (JSON or CSV) on `STDOUT`, -and writes early parser/connection failures as a structured `{ "status": "error", "error": ... }` -object on `STDERR`. The human-facing `user` and `table` formats are not machine mode; `user` +and writes parser, connection, and command failures as a structured `{ "status": "error", "error": ... }` +object on `STDERR`. `STDOUT` carries only the command result; tables and other interactive +views are not rendered. The human-facing `user` and `table` formats are not machine mode; `user` falls back to JSON whenever output is redirected, piped, or run in machine mode. +Quiet is not a universal mute: warnings, errors, and required instructions (such as +authentication prompts and previews shown before a confirmation) remain visible on +`STDERR`, without ANSI styling. Explicit diagnostic logging is unaffected. +Command results are separate from informational messages and remain on `STDOUT`. + Bare piped stdin (for example `echo "..." | cosmosdbshell`) is **not** implicitly machine mode; pass `-c`, `--output json`, or `--quiet` to opt into structured output. diff --git a/l10n/CosmosDBShell.json b/l10n/CosmosDBShell.json index dd59b5c4..17a5296f 100644 --- a/l10n/CosmosDBShell.json +++ b/l10n/CosmosDBShell.json @@ -1137,9 +1137,10 @@ "command-watch-description-format": "{0}", "command-watch-description-from-beginning": "Start from the beginning of the change feed instead of from now.", "command-watch-description-interval": "Seconds to wait between change feed polls once caught up. Defaults to 1; values below 0.1 are clamped.", - "command-watch-description-max": "Stop after receiving this many changes. Omit, or use 0 or a negative value, to follow until Ctrl\u002BC.", + "command-watch-description-max": "Stop after receiving this many changes. Required and positive in machine mode. In interactive mode, omit or use 0 or a negative value to follow until Ctrl\u002BC.", "command-watch-description-partition-key": "Scope the change feed to a single partition key value.", "command-watch-error-invalid_interval": "Interval value \u0027{0}\u0027 is invalid. Specify a finite number of seconds.", + "command-watch-error-machine_max_required": "Machine-mode watch requires a positive --max value. Use interactive mode to follow changes without a limit.", "command-watch-error-request_failed": "Change feed request failed with status code {0} ({1}).", "command-watch-example-1": "Tail new changes in the current container as they arrive", "command-watch-example-2": "Replay the change feed from the beginning of the container", @@ -1322,7 +1323,7 @@ "help-OptionsHeadingText": "OPTIONS:", "help-Otel": "Enable distributed tracing so requests carry a sampled W3C traceparent. Optionally specify an OTLP endpoint with --otel \u003Cendpoint\u003E; falls back to the OTEL_EXPORTER_OTLP_ENDPOINT environment variable.", "help-OutputFormat": "The output format to use (user, json, table, csv).", - "help-Quiet": "Suppresses standard informational output.", + "help-Quiet": "Suppress informational messages, progress, banners, and command echoes. Preserve results, warnings, errors, and required authentication instructions.", "help-RequiredWord": "Required.", "help-SentenceMutuallyExclusiveSetErrors": "{0}", "help-SentenceMutuallyExclusiveSetErrors.__p0.one": "-\u003E Option \u0027{0}\u0027 is not compatible with: {1}", From 5635e4c6735cc3cd4c6c0ebfa0746debd3eb9077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Fri, 16 Oct 2026 14:48:53 +0200 Subject: [PATCH 02/15] Preserve required prompts and theme validation diagnostics Address review feedback in #254 without changing singleton lifetime or cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Integration/ShellProcessTests.cs | 52 ++++++++++++++++++- .../SprocCommand.cs | 2 +- .../ThemeCommand.cs | 18 ++++++- .../TriggerCommand.cs | 2 +- .../UdfCommand.cs | 2 +- .../ShellInterpreter.cs | 9 ++-- docs/commands.md | 2 +- 8 files changed, 77 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c1d5f2..d9be99ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - `print` now reads items with typed scalar and complete hierarchical partition keys, for example `print customer-1001 '[1001,"customer-1001"]'`. Invalid keys are distinguished from missing items. Shared argument parsing rejects object/nested-array components and non-finite numbers instead of coercing them. ([#244](https://github.com/Azure/CosmosDBShell/issues/244)) - Avoid accessing an expired HTTP request during MCP session teardown, preventing disposal errors while releasing location subscriptions and legacy SSE admission state. ([#249](https://github.com/Azure/CosmosDBShell/issues/249)) - Report `theme`, `help`, and `edit` failures once, including a structured error on stderr in machine mode instead of an unexplained failure exit code. +- Include failing file paths and actionable diagnostics in machine-mode theme validation errors, and keep required script-editor confirmation instructions visible with `--quiet`. ### Improvements diff --git a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs index df55da48..47674d79 100644 --- a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs +++ b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs @@ -162,7 +162,21 @@ await File.WriteAllTextAsync( var line = Assert.Single(result.StdErr.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); using var error = JsonDocument.Parse(line); Assert.Equal("error", error.RootElement.GetProperty("status").GetString()); - Assert.DoesNotContain("unknown_warning_slot", result.StdErr); + var message = error.RootElement.GetProperty("error").GetString(); + Assert.NotNull(message); + if (strict) + { + Assert.Contains(Path.Join(path, "a-warning.toml"), message); + Assert.Contains("unknown_warning_slot", message); + } + else + { + Assert.Contains(Path.Join(path, "z-broken.toml"), message); + Assert.Contains("not_a_color", message); + Assert.DoesNotContain("unknown_warning_slot", message); + } + + Assert.DoesNotContain("\u001b", result.RawStdOut + result.RawStdErr, StringComparison.Ordinal); } finally { @@ -172,6 +186,42 @@ await File.WriteAllTextAsync( } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ThemeValidation_StrictFileFailure_IncludesDiagnosticsInSingleStructuredError(bool quiet) + { + var file = Path.Join(Path.GetTempPath(), $"cosmosshell-theme-strict-{Guid.NewGuid():N}.toml"); + try + { + await File.WriteAllTextAsync( + file, + "name = \"STRICT_WARNING_TEST\"\n[colors]\nunknown_warning_slot = \"red\"\n[styles]\nunknown_style_slot = \"bold\"\n", + TestContext.Current.CancellationToken); + var command = $"theme validate '{file}' --strict"; + var result = await RunShellAsync( + stdinScript: null, + extraArgs: quiet ? ["--quiet", "-c", command] : ["--output", "json", "-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ExitCode); + Assert.Empty(result.StdOut); + var line = Assert.Single(result.StdErr.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + using var error = JsonDocument.Parse(line); + Assert.Equal("error", error.RootElement.GetProperty("status").GetString()); + var message = error.RootElement.GetProperty("error").GetString(); + Assert.NotNull(message); + Assert.Contains(file, message); + Assert.Contains("unknown_warning_slot", message); + Assert.Contains("unknown_style_slot", message); + Assert.DoesNotContain("\u001b", result.RawStdOut + result.RawStdErr, StringComparison.Ordinal); + } + finally + { + File.Delete(file); + } + } + [Theory] [InlineData("--help", "USAGE:")] [InlineData("--version", "Cosmos")] diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs index eed0b217..9eae8d38 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -585,7 +585,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-sproc-edit-wait")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, MessageService.GetString("command-sproc-edit-wait")); Console.ReadLine(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs index d274afc7..0cac5bba 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs @@ -429,6 +429,11 @@ private CommandState RunValidateFile(CommandState commandState, string path) result.Name, "count", result.Warnings.Count); + if (ShellInterpreter.Instance.IsMachineMode) + { + strictMessage += Environment.NewLine + result.Source + ": " + string.Join(Environment.NewLine, result.Warnings); + } + throw new CommandException("theme", strictMessage); } @@ -481,6 +486,7 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire var fileResults = new List>(); var pendingWarnings = new List(); + var validationErrors = new List(); var validCount = 0; var invalidCount = 0; @@ -503,6 +509,7 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire if (failedStrict) { invalidCount++; + validationErrors.Add(result.Source + ": " + string.Join(Environment.NewLine, result.Warnings)); if (presentResults) { ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); @@ -534,6 +541,7 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire invalidCount++; entry["valid"] = false; entry["error"] = ex.Message; + validationErrors.Add(file + ": " + ex.Message); if (presentResults) { ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(System.IO.Path.GetFileName(file))}"); @@ -546,14 +554,20 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire if (invalidCount > 0) { - throw new CommandException("theme", MessageService.GetArgsString( + var message = MessageService.GetArgsString( "command-theme-validate-summary", "valid", validCount, "total", files.Length, "directory", - directory)); + directory); + if (!presentResults) + { + message += Environment.NewLine + string.Join(Environment.NewLine, validationErrors); + } + + throw new CommandException("theme", message); } foreach (var warning in pendingWarnings) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs index f62cd31f..5a5e3418 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TriggerCommand.cs @@ -551,7 +551,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-trigger-edit-wait")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, MessageService.GetString("command-trigger-edit-wait")); Console.ReadLine(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs index 94d4773e..a6d3c1f4 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs @@ -470,7 +470,7 @@ private async Task LaunchEditorAsync(string initialBody, string name, Ca // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetString("command-udf-edit-wait")); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.RequiredInstruction, MessageService.GetString("command-udf-edit-wait")); Console.ReadLine(); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 29964f9d..2fe00dbb 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -33,6 +33,11 @@ public partial class ShellInterpreter : IDisposable private const string SessionRequestChargeWarningThresholdVariable = "sessionRequestChargeWarningThreshold"; + // Declared before Instance: static initializers run in order and the constructor reads history. + private static readonly object HistoryFileLock = new(); + + internal static readonly ShellInterpreter Instance = new(); + private const int MAXHISTORYITEMS = 60; private const int HistoryFileOpenRetryCount = 10; @@ -48,10 +53,6 @@ public partial class ShellInterpreter : IDisposable // user command that just happens to start with the prefix string. private const string EncodedHistoryLineMarker = "E:"; - private static readonly object HistoryFileLock = new(); - - internal static readonly ShellInterpreter Instance = new(); - private static readonly TimeSpan HistoryFileOpenRetryDelay = TimeSpan.FromMilliseconds(25); private static readonly TimeSpan LocalEmulatorOperationTimeout = TimeSpan.FromSeconds(10); diff --git a/docs/commands.md b/docs/commands.md index 57b6cc5a..2e7ad274 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -365,7 +365,7 @@ theme reload `theme validate` parses a TOML file and reports warnings without registering it or switching the active theme. When the argument is a directory it validates every `*.toml` file in that directory and prints a per-file summary. With no argument it scans the user themes directory (`~/.cosmosdbshell/themes`). The validator collects every issue in a single pass so that multiple typos can be fixed at once, and suggests the closest valid token when an unknown color or modifier is used. It also warns on bracket cycles that have only one color or contain duplicates. Pass `--strict` to fail when any warnings are present. Color values must be empty or one ANSI 16 color name. Style values may combine modifiers with at most one ANSI 16 color. -In machine mode (including `--quiet`), successful validation keeps warnings on stderr and the structured result on stdout. Directory scans defer warnings until every file has been checked; failed scans emit only the structured error on stderr. +In machine mode (including `--quiet`), successful validation keeps warnings on stderr and the structured result on stdout. Directory scans defer warnings until every file has been checked; failed scans emit one structured error on stderr, including each failing file's path and its errors or strict-mode warnings. Strict single-file failures likewise include the path and warning details in the structured error. ## Data Operations From 813b0f5034744f5c3dffaf851fb413b295613fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Fri, 16 Oct 2026 15:04:59 +0200 Subject: [PATCH 03/15] Enforce console clearing policy and preserve quiet editor history Address the previously missed overview findings in #254 and add focused analyzer and editor-history regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../DirectConsoleOutputAnalyzer.cs | 4 +- .../CosmosDBShell.Tests.csproj | 2 + .../DirectConsoleOutputAnalyzerTests.cs | 76 +++++++++++++++++++ .../Shell/NonAnsiFallbackTests.cs | 47 ++++++++++++ .../ShellInterpreter.cs | 5 -- 6 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 CosmosDBShell.Tests/DirectConsoleOutputAnalyzerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d9be99ba..df2aced8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Avoid accessing an expired HTTP request during MCP session teardown, preventing disposal errors while releasing location subscriptions and legacy SSE admission state. ([#249](https://github.com/Azure/CosmosDBShell/issues/249)) - Report `theme`, `help`, and `edit` failures once, including a structured error on stderr in machine mode instead of an unexplained failure exit code. - Include failing file paths and actionable diagnostics in machine-mode theme validation errors, and keep required script-editor confirmation instructions visible with `--quiet`. +- Keep quiet command echoes in an existing editor's Up/Down history without printing them. ### Improvements diff --git a/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs b/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs index d0afeb36..c7729c93 100644 --- a/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs +++ b/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs @@ -72,8 +72,8 @@ private static bool IsDirectOutput(SemanticModel model, InvocationExpressionSynt { var type = method.ContainingType?.ToDisplayString(); - // Console.Write / Console.WriteLine. - if (method.IsStatic && type == "System.Console" && method.Name.StartsWith("Write")) + // Console.Write / Console.WriteLine / Console.Clear. + if (method.IsStatic && type == "System.Console" && (method.Name.StartsWith("Write") || method.Name == "Clear")) { return true; } diff --git a/CosmosDBShell.Tests/CosmosDBShell.Tests.csproj b/CosmosDBShell.Tests/CosmosDBShell.Tests.csproj index 67ef300a..e70d6549 100644 --- a/CosmosDBShell.Tests/CosmosDBShell.Tests.csproj +++ b/CosmosDBShell.Tests/CosmosDBShell.Tests.csproj @@ -22,6 +22,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -32,6 +33,7 @@ + diff --git a/CosmosDBShell.Tests/DirectConsoleOutputAnalyzerTests.cs b/CosmosDBShell.Tests/DirectConsoleOutputAnalyzerTests.cs new file mode 100644 index 00000000..450b6eed --- /dev/null +++ b/CosmosDBShell.Tests/DirectConsoleOutputAnalyzerTests.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests; + +using System.Collections.Immutable; +using CosmosShell.Analyzer; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +public class DirectConsoleOutputAnalyzerTests +{ + [Theory] + [InlineData("Console.Clear();", true)] + [InlineData("Console.Write(\"text\");", true)] + [InlineData("Console.WriteLine(\"text\");", true)] + [InlineData("Console.Out.WriteLine(\"text\");", true)] + [InlineData("Console.Error.WriteLine(\"text\");", true)] + [InlineData("Console.ReadLine();", false)] + [InlineData("var width = Console.WindowWidth;", false)] + [InlineData("using var writer = new System.IO.StringWriter(); writer.WriteLine(\"text\");", false)] + public async Task DirectConsoleOperations_EnforceOutputBoundary(string statement, bool reportsDiagnostic) + { + var diagnostics = await AnalyzeAsync("Example", statement); + + if (reportsDiagnostic) + { + Assert.Equal(DirectConsoleOutputAnalyzer.DiagnosticId, Assert.Single(diagnostics).Id); + } + else + { + Assert.Empty(diagnostics); + } + } + + [Theory] + [InlineData("Console.Clear();")] + [InlineData("Console.WriteLine(\"text\");")] + public async Task ShellOutput_CanAccessConsole(string statement) + { + Assert.Empty(await AnalyzeAsync("Azure.Data.Cosmos.Shell.Core.ShellOutput", statement)); + } + + private static async Task> AnalyzeAsync(string typeName, string statement) + { + var separator = typeName.LastIndexOf('.'); + var namespaceName = separator < 0 ? "ExampleNamespace" : typeName[..separator]; + var className = typeName[(separator + 1)..]; + var source = $$""" + using System; + namespace {{namespaceName}}; + internal class {{className}} + { + internal void Run() + { + {{statement}} + } + } + """; + var assemblies = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") + ?? throw new InvalidOperationException("Runtime assembly references are unavailable."); + var references = assemblies.Split(Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)); + var compilation = CSharpCompilation.Create( + "AnalyzerTest", + [CSharpSyntaxTree.ParseText(source)], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + Assert.DoesNotContain(compilation.GetDiagnostics(), diagnostic => diagnostic.Severity == DiagnosticSeverity.Error); + + return await compilation.WithAnalyzers([new DirectConsoleOutputAnalyzer()]) + .GetAnalyzerDiagnosticsAsync(TestContext.Current.CancellationToken); + } +} diff --git a/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs b/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs index c5a7d041..71074661 100644 --- a/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs +++ b/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs @@ -10,6 +10,53 @@ namespace CosmosShell.Tests.Shell; [Collection(ThemeStateTestCollection.Name)] public class NonAnsiFallbackTests { + [Fact] + public void PrintCommand_Quiet_RecordsInExistingEditorHistoryWithoutEcho() + { + var configPath = CreateConfigPath(); + var savedConsole = AnsiConsole.Console; + var savedOut = Console.Out; + var savedError = Console.Error; + using var output = new StringWriter(); + using var error = new StringWriter(); + try + { + var ansiConsole = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.Yes, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(output), + Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }, + }); + AnsiConsole.Console = ansiConsole; + Console.SetOut(output); + Console.SetError(error); + using var shell = new ShellInterpreter(configPath) + { + Options = new Program.CosmosShellOptions { Quiet = true }, + LineEditorTerminal = ansiConsole, + }; + var editor = shell.Editor; + Assert.NotNull(editor); + var historyCount = editor.History.Count; + + shell.PrintCommand("echo QUIET_HISTORY"); + + Assert.Equal(historyCount + 1, editor.History.Count); + Assert.Contains("echo QUIET_HISTORY", shell.History); + Assert.Contains("echo QUIET_HISTORY", File.ReadAllText(shell.HistoryFile)); + Assert.Empty(output.ToString()); + Assert.Empty(error.ToString()); + } + finally + { + Console.SetOut(savedOut); + Console.SetError(savedError); + AnsiConsole.Console = savedConsole; + DeleteConfigPath(configPath); + } + } + [Fact] public async Task RunAsync_WhenLineEditorCannotBeCreated_UsesPromptFallbackAndExitsOnEof() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 2fe00dbb..aced1c9b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -1907,11 +1907,6 @@ internal void PrintCommand(string cmdString) System.Diagnostics.Debug.WriteLine(ex); } - if (this.Options?.Quiet == true) - { - return; - } - // Echoing and the line editor both need an ANSI terminal, which an MCP host may not // provide. Neither may fail the command being announced. try From 7031125d3ca50d9162fe0dfb8d9cae63722941bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Fri, 16 Oct 2026 15:24:12 +0200 Subject: [PATCH 04/15] Preserve machine-mode write and output error details Address remaining overview findings in #254. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../MachineModeWriteErrorTests.cs | 216 ++++++++++++++++++ .../Integration/ShellProcessTests.cs | 42 ++++ .../ImportCommand.cs | 39 +++- .../MakeItemCommand.cs | 54 +++-- .../ReplaceCommand.cs | 24 +- .../ShellInterpreter.cs | 2 +- CosmosDBShell/lang/en.ftl | 1 + docs/navigation.md | 4 + l10n/CosmosDBShell.json | 1 + 10 files changed, 339 insertions(+), 45 deletions(-) create mode 100644 CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index df2aced8..cd3f6d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Report `theme`, `help`, and `edit` failures once, including a structured error on stderr in machine mode instead of an unexplained failure exit code. - Include failing file paths and actionable diagnostics in machine-mode theme validation errors, and keep required script-editor confirmation instructions visible with `--quiet`. - Keep quiet command echoes in an existing editor's Up/Down history without printing them. +- Preserve per-item failure diagnostics in machine-mode import, bulk insert/upsert, and replacement errors. Verbose output-formatting and file-redirection failures also retain structured stderr output and `2>` redirection. ### Improvements diff --git a/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs new file mode 100644 index 00000000..92d14821 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs @@ -0,0 +1,216 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.CommandTests; + +using System.Net; +using System.Text.Json; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.States; +using Microsoft.Azure.Cosmos; +using NSubstitute; + +[Collection(ConsoleOutputTestCollection.Name)] +public class MachineModeWriteErrorTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Import_PreservesLineStatusAndServiceDiagnostics(bool upsert, bool quiet) + { + var (shell, container) = CreateShell(quiet); + using (shell) + { + ConfigureWrites(container); + var path = Path.Join(Path.GetTempPath(), $"cosmosshell-import-errors-{Guid.NewGuid():N}.jsonl"); + try + { + await File.WriteAllTextAsync( + path, + "{\"id\":\"bad-status\",\"pk\":\"x\"}\n{\"id\":\"denied\",\"pk\":\"x\"}\n", + TestContext.Current.CancellationToken); + var command = $"import '{path}' --continue-on-error --concurrency=2" + + (upsert ? " --mode=upsert" : string.Empty); + var error = await CaptureMachineErrorAsync(shell, command); + + Assert.Contains("Line 1", error); + Assert.Contains("BadRequest", error); + Assert.Contains("Line 2", error); + Assert.Contains("Forbidden", error); + Assert.Contains("bulk service denied", error); + } + finally + { + File.Delete(path); + } + } + } + + [Fact] + public async Task Import_ParseFailure_PreservesEarlierWriteDiagnostics() + { + var (shell, container) = CreateShell(quiet: true); + using (shell) + { + ConfigureWrites(container); + var path = Path.Join(Path.GetTempPath(), $"cosmosshell-import-parse-errors-{Guid.NewGuid():N}.jsonl"); + try + { + await File.WriteAllTextAsync( + path, + "{\"id\":\"bad-status\",\"pk\":\"x\"}\nnot-json\n", + TestContext.Current.CancellationToken); + var error = await CaptureMachineErrorAsync(shell, $"import '{path}' --continue-on-error --concurrency=2"); + + Assert.Contains("Line 1", error); + Assert.Contains("BadRequest", error); + Assert.Contains("2", error); + Assert.Contains("JSON", error, StringComparison.OrdinalIgnoreCase); + } + finally + { + File.Delete(path); + } + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task MakeItem_Bulk_PreservesStatusAndServiceDiagnostics(bool upsert, bool quiet) + { + var (shell, container) = CreateShell(quiet); + using (shell) + { + ConfigureWrites(container); + var command = "mkitem '[{\"id\":\"bad-status\",\"pk\":\"x\"},{\"id\":\"denied\",\"pk\":\"x\"}]'" + + (upsert ? " --force" : string.Empty); + var error = await CaptureMachineErrorAsync(shell, command); + + Assert.Contains("Item 1", error); + Assert.Contains("BadRequest", error); + Assert.Contains("Item 2", error); + Assert.Contains("Forbidden", error); + Assert.Contains("bulk service denied", error); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MakeItem_SingleFailedStatus_ReportsStructuredError(bool upsert) + { + var (shell, container) = CreateShell(quiet: true); + using (shell) + { + ConfigureWrites(container); + var command = "mkitem '{\"id\":\"bad-status\",\"pk\":\"x\"}'" + (upsert ? " --force" : string.Empty); + Assert.Contains("BadRequest", await CaptureMachineErrorAsync(shell, command)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Replace_Bulk_PreservesValidationStatusAndServiceDiagnostics(bool quiet) + { + var (shell, container) = CreateShell(quiet); + using (shell) + { + ConfigureWrites(container); + var error = await CaptureMachineErrorAsync( + shell, + "replace '[{},{\"id\":\"bad-status\",\"pk\":\"x\"},{\"id\":\"denied\",\"pk\":\"x\"}]'"); + + Assert.Contains("Item 1", error); + Assert.Contains("non-empty 'id'", error); + Assert.Contains("Item 2", error); + Assert.Contains("BadRequest", error); + Assert.Contains("Item 3", error); + Assert.Contains("Forbidden", error); + Assert.Contains("bulk service denied", error); + } + } + + private static (ShellInterpreter Shell, Container Container) CreateShell(bool quiet) + { + var client = Substitute.For(); + client.Endpoint.Returns(new Uri("https://account.documents.azure.com:443/")); + var database = Substitute.For(); + var container = Substitute.For(); + client.GetDatabase("db").Returns(database); + database.GetContainer("items").Returns(container); + var databaseResponse = Substitute.For(); + databaseResponse.StatusCode.Returns(HttpStatusCode.OK); + database.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(databaseResponse)); + var containerResponse = Substitute.For(); + containerResponse.StatusCode.Returns(HttpStatusCode.OK); + containerResponse.Resource.Returns(new ContainerProperties("items", "/pk")); + container.ReadContainerAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(containerResponse)); + + var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { Output = "json", Quiet = quiet }; + shell.State = new ContainerState("items", "db", client); + return (shell, container); + } + + private static void ConfigureWrites(Container container) + { + Task> Write(JsonElement item) + { + if (item.GetProperty("id").GetString() == "bad-status") + { + var response = Substitute.For>(); + response.StatusCode.Returns(HttpStatusCode.BadRequest); + return Task.FromResult(response); + } + + return Task.FromException>( + new CosmosException("bulk service denied", HttpStatusCode.Forbidden, 0, "test", 0)); + } + + container.CreateItemAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => Write(call.ArgAt(0))); + container.UpsertItemAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => Write(call.ArgAt(0))); + container.ReplaceItemAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => Write(call.ArgAt(0))); + } + + private static async Task CaptureMachineErrorAsync(ShellInterpreter shell, string command) + { + var savedOptions = ShellInterpreter.Instance.Options; + var savedOut = Console.Out; + var savedError = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + try + { + ShellInterpreter.Instance.Options = shell.Options; + Console.SetOut(stdout); + Console.SetError(stderr); + var state = await shell.ExecuteCommandAsync(command, TestContext.Current.CancellationToken); + + Assert.True(state.IsError); + Assert.Empty(stdout.ToString()); + var line = Assert.Single(stderr.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + using var json = JsonDocument.Parse(line); + Assert.Equal("error", json.RootElement.GetProperty("status").GetString()); + Assert.DoesNotContain("\u001b", line, StringComparison.Ordinal); + return Assert.IsType(json.RootElement.GetProperty("error").GetString()); + } + finally + { + Console.SetOut(savedOut); + Console.SetError(savedError); + ShellInterpreter.Instance.Options = savedOptions; + } + } +} diff --git a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs index 47674d79..083ad2b9 100644 --- a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs +++ b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs @@ -24,6 +24,48 @@ public class ShellProcessTests { private static readonly Regex AnsiEscape = new("\x1b\\[[0-9;?]*[ -/]*[@-~]", RegexOptions.Compiled); + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task MachineMode_OutputFailure_RemainsStructuredAndHonorsStderrRedirection(bool verbose, bool redirectError) + { + var directory = Path.Join(Path.GetTempPath(), $"cosmosshell-output-errors-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var outputFile = Path.Join(directory, "missing", "out.txt"); + var errorFile = Path.Join(directory, "error.json"); + try + { + var command = $"echo ok > '{outputFile}'" + (redirectError ? $" 2> '{errorFile}'" : string.Empty); + var result = await RunShellAsync( + stdinScript: null, + extraArgs: verbose ? ["--verbose", "-c", command] : ["-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(1, result.ExitCode); + Assert.Empty(result.StdOut); + var errors = redirectError + ? await File.ReadAllTextAsync(errorFile, TestContext.Current.CancellationToken) + : result.StdErr; + if (redirectError) + { + Assert.Empty(result.StdErr); + } + + var line = Assert.Single(errors.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + using var error = JsonDocument.Parse(line); + Assert.Equal("error", error.RootElement.GetProperty("status").GetString()); + Assert.Contains(outputFile, error.RootElement.GetProperty("error").GetString()); + Assert.DoesNotContain("\u001b", errors + result.RawStdOut + result.RawStdErr, StringComparison.Ordinal); + } + finally + { + File.Delete(errorFile); + Directory.Delete(directory); + } + } + [Theory] [InlineData("theme use MACHINE_NOPE", "MACHINE_NOPE")] [InlineData("help MACHINE_MISSING", "MACHINE_MISSING")] diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs index 4e6815b4..e4af4ff7 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs @@ -495,7 +495,8 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co var continueOnError = this.ContinueOnError == true; var partitionKeySegments = ParsePartitionKeySegments(this.PartitionKey); - var (successCount, failCount, charge) = await ExecuteImportAsync(filePath, format, mode, container, continueOnError, dryRun, partitionKeySegments, concurrency, token); + var errors = new List(); + var (successCount, failCount, charge) = await ExecuteImportAsync(filePath, format, mode, container, continueOnError, dryRun, partitionKeySegments, concurrency, errors, token); if (!shell.IsMachineMode) { @@ -537,14 +538,18 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co if (failCount > 0) { - throw new CommandException( - "import", - MessageService.GetArgsString( - "command-import-error-some_failed", - "failed", - failCount, - "total", - successCount + failCount)); + var message = MessageService.GetArgsString( + "command-import-error-some_failed", + "failed", + failCount, + "total", + successCount + failCount); + if (shell.IsMachineMode) + { + message += Environment.NewLine + string.Join(Environment.NewLine, errors); + } + + throw new CommandException("import", message); } return new CommandState @@ -590,6 +595,7 @@ private static async Task ResolveFormatAsync(string filePath, Impo bool dryRun, string[]? partitionKeySegments, int concurrency, + List errors, CancellationToken token) { FileStream? stream = null; @@ -627,7 +633,11 @@ private static async Task ResolveFormatAsync(string filePath, Impo } ArgumentNullException.ThrowIfNull(container); - return await WriteItemsAsync(source, container, mode, concurrency, continueOnError, token); + return await WriteItemsAsync(source, container, mode, concurrency, continueOnError, token, errors); + } + catch (CommandException ex) when (ShellInterpreter.Instance.IsMachineMode && errors.Count > 0) + { + throw new CommandException("import", ex.Message + Environment.NewLine + string.Join(Environment.NewLine, errors), ex); } finally { @@ -645,7 +655,8 @@ private static async Task ResolveFormatAsync(string filePath, Impo ImportMode mode, int concurrency, bool continueOnError, - CancellationToken token) + CancellationToken token, + ICollection? errors = null) { ArgumentOutOfRangeException.ThrowIfLessThan(concurrency, 1); var pending = new List>(); @@ -663,7 +674,11 @@ void Record((double Charge, string? Error) result) else { failed++; - if (!ShellInterpreter.Instance.IsMachineMode) + if (ShellInterpreter.Instance.IsMachineMode) + { + errors?.Add(result.Error); + } + else { ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, result.Error); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs index 6bbf1f5c..0be93942 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs @@ -148,8 +148,11 @@ private static async Task WriteItemAsync(Container container, stri int failCount = 0; double charge = 0.0; var presentResults = !ShellInterpreter.Instance.IsMachineMode; + var errors = new List(); + var itemIndex = 0; foreach (var element in root.EnumerateArray()) { + itemIndex++; try { ItemResponse result = force @@ -167,14 +170,14 @@ private static async Task WriteItemAsync(Container container, stri else { failCount++; + var message = MessageService.GetArgsString( + "command-mkitem-error-status-returned", + "status", + result.StatusCode.ToString()); + errors.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", message)); if (presentResults) { - ShellInterpreter.Instance.Output.WriteLine( - ShellMessageKind.Error, - MessageService.GetArgsString( - "command-mkitem-error-status-returned", - "status", - result.StatusCode.ToString())); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, message); } } } @@ -182,16 +185,16 @@ private static async Task WriteItemAsync(Container container, stri { RequestChargeContext.Record(ce.RequestCharge); failCount++; + var message = MessageService.GetArgsString( + "command-mkitem-error-creation-failed", + "status", + ce.StatusCode.ToString(), + "message", + CommandException.GetDisplayMessage(ce)); + errors.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", message)); if (presentResults) { - ShellInterpreter.Instance.Output.WriteLine( - ShellMessageKind.Error, - MessageService.GetArgsString( - "command-mkitem-error-creation-failed", - "status", - ce.StatusCode.ToString(), - "message", - CommandException.GetDisplayMessage(ce))); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, message); } } } @@ -279,14 +282,18 @@ private static async Task WriteItemAsync(Container container, stri if (failCount > 0) { var totalCount = createdCount + replacedCount + failCount; - throw new CommandException( - "mkitem", - MessageService.GetArgsString( - "command-mkitem-error-array_failed", - "failed", - failCount, - "total", - totalCount)); + var message = MessageService.GetArgsString( + "command-mkitem-error-array_failed", + "failed", + failCount, + "total", + totalCount); + if (!presentResults) + { + message += Environment.NewLine + string.Join(Environment.NewLine, errors); + } + + throw new CommandException("mkitem", message); } return new WriteSummary(createdCount, replacedCount, failCount, charge); @@ -318,11 +325,10 @@ private static async Task WriteItemAsync(Container container, stri } else { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString( + throw new CommandException("mkitem", MessageService.GetArgsString( "command-mkitem-error-status-returned", "status", result.StatusCode.ToString())); - return new WriteSummary(0, 0, 1, result.RequestCharge); } } catch (CosmosException ce) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs index 9ebca183..8dca4c6c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs @@ -99,9 +99,12 @@ private static async Task ReplaceArrayAsync(Container container, int failCount = 0; double charge = 0.0; var presentResults = !ShellInterpreter.Instance.IsMachineMode; + var errors = new List(); + var itemIndex = 0; foreach (var element in arrayRoot.EnumerateArray()) { + itemIndex++; try { var itemCharge = await ReplaceOneAsync(container, partitionKeyPaths, element, etag: null, token, printSuccess: false); @@ -112,6 +115,7 @@ private static async Task ReplaceArrayAsync(Container container, { RequestChargeContext.Record(RequestChargeContext.GetCosmosExceptionCharge(ex)); failCount++; + errors.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", ex.Message)); if (presentResults) { ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, ex.Message); @@ -138,14 +142,18 @@ private static async Task ReplaceArrayAsync(Container container, if (failCount > 0) { RequestChargeContext.Record(charge); - throw new CommandException( - "replace", - MessageService.GetArgsString( - "command-replace-error-array_failed", - "failed", - failCount, - "total", - successCount + failCount)); + var message = MessageService.GetArgsString( + "command-replace-error-array_failed", + "failed", + failCount, + "total", + successCount + failCount); + if (!presentResults) + { + message += Environment.NewLine + string.Join(Environment.NewLine, errors); + } + + throw new CommandException("replace", message); } return new ReplaceSummary(successCount, failCount, charge); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index aced1c9b..92a1a7b6 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -2034,7 +2034,7 @@ internal CommandState PrintState(CommandState state, bool markAsRendered = false } catch (Exception e) { - if (this.Options?.Verbose == true) + if (this.Options?.Verbose == true && !this.IsMachineMode) { this.Output.WriteException(ShellMessageKind.Error, e); return new ErrorCommandState(e); diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index dfb52f16..e155b176 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -177,6 +177,7 @@ error-database_not_found = Database '{ $database }' not found. error-container_not_found = Container '{ $container }' not found in database '{ $database }'. Use 'ls --database { $database }' to see available containers. error-no_input_data = No input data. +error-bulk-item = Item { $index }: { $message } error-argument_parse_redirect = Invalid redirection error-param_parse = Invalid value for '{ $name }' valid values: { $values } error-too_many_arguments = Too many arguments ({$expected} expected) diff --git a/docs/navigation.md b/docs/navigation.md index 55368246..79fe8ebf 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -311,6 +311,10 @@ Quiet is not a universal mute: warnings, errors, and required instructions (such authentication prompts and previews shown before a confirmation) remain visible on `STDERR`, without ANSI styling. Explicit diagnostic logging is unaffected. Command results are separate from informational messages and remain on `STDOUT`. +Bulk-write errors include per-item diagnostics in that single structured error: +imports identify source lines, and item-array writes identify 1-based item positions. +`--verbose` does not replace machine-mode errors with raw stack traces; shell `2>` +redirection still applies. Bare piped stdin (for example `echo "..." | cosmosdbshell`) is **not** implicitly machine mode; pass `-c`, `--output json`, or `--quiet` to opt into structured output. diff --git a/l10n/CosmosDBShell.json b/l10n/CosmosDBShell.json index 17a5296f..6075b61e 100644 --- a/l10n/CosmosDBShell.json +++ b/l10n/CosmosDBShell.json @@ -1195,6 +1195,7 @@ "error-arm-context-endpoint-mismatch": "The Azure Resource Manager account endpoint \u0027{0}\u0027 does not match the connected Cosmos DB endpoint \u0027{1}\u0027. Reconnect with the subscription and resource group that own the connected account.", "error-arm-context-incomplete": "Provide subscription and resource group together to use an explicit Azure Resource Manager account context. The account name is inferred from the endpoint.", "error-arm-context-required": "Database and container resource operations require Azure Resource Manager context. Reconnect with Entra ID and provide --subscription and --resource-group, or use --connect-subscription and --connect-resource-group at startup. Alternatively, use an identity that can discover the Cosmos DB account through ARM.", + "error-bulk-item": "Item {0}: {1}", "error-command-not-found": "\u0027{0}\u0027 is not recognized as an internal or external command, operable program or batch file.", "error-command-not-found-connection-string": "This looks like part of a connection string. The shell treats \u0027;\u0027 as a command separator, so wrap the whole value in quotes, e.g. connect \u0022AccountEndpoint=https://localhost:8081/;AccountKey=...;\u0022.", "error-command-not-found-suggestion": "Did you mean \u0027{0}\u0027?", From 7292df4bc0eada85c499a84176fc111cf199be2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Mon, 19 Oct 2026 09:34:35 +0200 Subject: [PATCH 05/15] Bound machine-mode import diagnostics --- .../MachineModeWriteErrorTests.cs | 69 +++++++++++++++++++ .../ImportCommand.cs | 55 +++++++++++++-- CosmosDBShell/lang/en.ftl | 2 + l10n/CosmosDBShell.json | 2 + 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs index 92d14821..dd0773f8 100644 --- a/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs +++ b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs @@ -6,6 +6,7 @@ namespace CosmosShell.Tests.CommandTests; using System.Net; using System.Text.Json; +using Azure.Data.Cosmos.Shell.Commands; using Azure.Data.Cosmos.Shell.Core; using Azure.Data.Cosmos.Shell.States; using Microsoft.Azure.Cosmos; @@ -77,6 +78,74 @@ await File.WriteAllTextAsync( } } + [Fact] + public async Task Import_ArrayParseFailure_PreservesEarlierWriteDiagnostics() + { + var (shell, container) = CreateShell(quiet: true); + using (shell) + { + ConfigureWrites(container); + var path = Path.Join(Path.GetTempPath(), $"cosmosshell-import-array-errors-{Guid.NewGuid():N}.json"); + try + { + await File.WriteAllTextAsync( + path, + "[{\"id\":\"bad-status\",\"pk\":\"x\"},{\"id\":\"denied\",\"pk\":\"x\"},", + TestContext.Current.CancellationToken); + var error = await CaptureMachineErrorAsync(shell, $"import '{path}' --continue-on-error --concurrency=2"); + + Assert.Contains("Line 1", error); + Assert.Contains("BadRequest", error); + Assert.Contains("Line 2", error); + Assert.Contains("Forbidden", error); + Assert.Contains("bulk service denied", error); + Assert.Contains("Expected start of a property name or value", error); + } + finally + { + File.Delete(path); + } + } + } + + [Fact] + public async Task Import_TooManyFailures_BoundsDiagnosticsAndReportsOmittedCount() + { + var (shell, container) = CreateShell(quiet: true); + using (shell) + { + ConfigureWrites(container); + var path = Path.Join(Path.GetTempPath(), $"cosmosshell-import-many-errors-{Guid.NewGuid():N}.jsonl"); + try + { + var records = Enumerable.Repeat("{\"id\":\"denied\",\"pk\":\"x\"}", ImportCommand.ImportErrorDiagnostics.MaximumCount + 3); + await File.WriteAllLinesAsync(path, records, TestContext.Current.CancellationToken); + var error = await CaptureMachineErrorAsync(shell, $"import '{path}' --continue-on-error --concurrency=16"); + + Assert.Equal( + ImportCommand.ImportErrorDiagnostics.MaximumCount, + error.Split("Line ", StringSplitOptions.None).Length - 1); + Assert.Contains("3 additional item diagnostics omitted", error); + } + finally + { + File.Delete(path); + } + } + } + + [Fact] + public void ImportErrorDiagnostics_TruncatesLongMessages() + { + var diagnostics = new ImportCommand.ImportErrorDiagnostics(); + diagnostics.Add(new string('x', ImportCommand.ImportErrorDiagnostics.MaximumLength + 100)); + + var message = diagnostics.GetMessage(); + + Assert.True(message.Length <= ImportCommand.ImportErrorDiagnostics.MaximumLength); + Assert.Contains("[diagnostic truncated]", message); + } + [Theory] [InlineData(false, false)] [InlineData(false, true)] diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs index e4af4ff7..4b3eab76 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs @@ -495,7 +495,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co var continueOnError = this.ContinueOnError == true; var partitionKeySegments = ParsePartitionKeySegments(this.PartitionKey); - var errors = new List(); + var errors = new ImportErrorDiagnostics(); var (successCount, failCount, charge) = await ExecuteImportAsync(filePath, format, mode, container, continueOnError, dryRun, partitionKeySegments, concurrency, errors, token); if (!shell.IsMachineMode) @@ -546,7 +546,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co successCount + failCount); if (shell.IsMachineMode) { - message += Environment.NewLine + string.Join(Environment.NewLine, errors); + message += Environment.NewLine + errors.GetMessage(); } throw new CommandException("import", message); @@ -595,7 +595,7 @@ private static async Task ResolveFormatAsync(string filePath, Impo bool dryRun, string[]? partitionKeySegments, int concurrency, - List errors, + ImportErrorDiagnostics errors, CancellationToken token) { FileStream? stream = null; @@ -635,9 +635,10 @@ private static async Task ResolveFormatAsync(string filePath, Impo ArgumentNullException.ThrowIfNull(container); return await WriteItemsAsync(source, container, mode, concurrency, continueOnError, token, errors); } - catch (CommandException ex) when (ShellInterpreter.Instance.IsMachineMode && errors.Count > 0) + catch (Exception ex) when ((ex is CommandException or JsonException) + && ShellInterpreter.Instance.IsMachineMode && errors.Count > 0) { - throw new CommandException("import", ex.Message + Environment.NewLine + string.Join(Environment.NewLine, errors), ex); + throw new CommandException("import", ex.Message + Environment.NewLine + errors.GetMessage(), ex); } finally { @@ -656,7 +657,7 @@ private static async Task ResolveFormatAsync(string filePath, Impo int concurrency, bool continueOnError, CancellationToken token, - ICollection? errors = null) + ImportErrorDiagnostics? errors = null) { ArgumentOutOfRangeException.ThrowIfLessThan(concurrency, 1); var pending = new List>(); @@ -779,6 +780,48 @@ async Task DrainCompletedAsync() } } + internal sealed class ImportErrorDiagnostics + { + internal const int MaximumCount = 100; + internal const int MaximumLength = 2048; + + private readonly List errors = []; + private long omittedCount; + + internal int Count => this.errors.Count; + + internal void Add(string error) + { + if (this.errors.Count >= MaximumCount) + { + this.omittedCount++; + return; + } + + if (error.Length > MaximumLength) + { + var marker = MessageService.GetString("command-import-error-diagnostic_truncated"); + error = error[..(MaximumLength - marker.Length)] + marker; + } + + this.errors.Add(error); + } + + internal string GetMessage() + { + var message = string.Join(Environment.NewLine, this.errors); + if (this.omittedCount > 0) + { + message += Environment.NewLine + MessageService.GetArgsString( + "command-import-error-diagnostics_omitted", + "count", + this.omittedCount); + } + + return message; + } + } + private sealed class CancellationAwareTextReader(TextReader inner, CancellationToken token) : TextReader { public override int Peek() diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index e155b176..999e8681 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -523,6 +523,8 @@ command-import-error-invalid_line_json = Line { $line } is not valid JSON: { $me command-import-error-item_status = Line { $line }: item returned status { $status }. command-import-error-item_failed = Line { $line }: { $status } - { $message } command-import-error-some_failed = Failed to import { $failed } of { $total } items. +command-import-error-diagnostic_truncated = [diagnostic truncated] +command-import-error-diagnostics_omitted = { $count } additional item diagnostics omitted. command-import-error-csv_pk_conflict = CSV column '{ $column }' conflicts with the partition key path '{ $path }': the column holds a scalar value but the path requires it to be a nested object. Rename the column or choose a different partition key path. diff --git a/l10n/CosmosDBShell.json b/l10n/CosmosDBShell.json index 6075b61e..ac7cf1a1 100644 --- a/l10n/CosmosDBShell.json +++ b/l10n/CosmosDBShell.json @@ -417,6 +417,8 @@ "command-import-dry-run-success.__p1.other": "items", "command-import-error-blank_line": "Line {0} is blank.", "command-import-error-csv_pk_conflict": "CSV column \u0027{0}\u0027 conflicts with the partition key path \u0027{1}\u0027: the column holds a scalar value but the path requires it to be a nested object. Rename the column or choose a different partition key path.", + "command-import-error-diagnostic_truncated": "[diagnostic truncated]", + "command-import-error-diagnostics_omitted": "{0} additional item diagnostics omitted.", "command-import-error-file_not_found": "File \u0027{0}\u0027 was not found.", "command-import-error-invalid_concurrency": "Concurrency must be a positive integer.", "command-import-error-invalid_csv": "Invalid CSV record at line {0}.", From 52c408bffccf32c9666d6c118b7f34f9290370a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Mon, 19 Oct 2026 13:00:12 +0200 Subject: [PATCH 06/15] Use the executing shell for command output and disable CI ANSI enrichers mkitem, replace, import, and theme now read machine mode and output from the shell passed to ExecuteAsync instead of the singleton. ShellOutput's plain console no longer lets Spectre's CI profile enrichers re-enable ANSI escapes in machine-mode stderr. --- .../MachineModeWriteErrorTests.cs | 3 - .../CommandTests/ThemeCommandTests.cs | 35 ++++++ .../ImportCommand.cs | 22 ++-- .../MakeItemCommand.cs | 24 ++-- .../ReplaceCommand.cs | 22 ++-- .../ThemeCommand.cs | 114 +++++++++--------- .../ShellOutput.cs | 3 + 7 files changed, 129 insertions(+), 94 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs index 0f276597..24a812f1 100644 --- a/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs +++ b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs @@ -293,14 +293,12 @@ static Task WriteStream(Stream body) private static async Task CaptureMachineErrorAsync(ShellInterpreter shell, string command) { - var savedOptions = ShellInterpreter.Instance.Options; var savedOut = Console.Out; var savedError = Console.Error; using var stdout = new StringWriter(); using var stderr = new StringWriter(); try { - ShellInterpreter.Instance.Options = shell.Options; Console.SetOut(stdout); Console.SetError(stderr); var state = await shell.ExecuteCommandAsync(command, TestContext.Current.CancellationToken); @@ -317,7 +315,6 @@ private static async Task CaptureMachineErrorAsync(ShellInterpreter shel { Console.SetOut(savedOut); Console.SetError(savedError); - ShellInterpreter.Instance.Options = savedOptions; } } } diff --git a/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs index a65e3d4a..bf45bc0d 100644 --- a/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs @@ -86,6 +86,41 @@ public async Task Validate_ScansDirectoryAndReportsCounts() Assert.Contains("1 of 2", error.Message); } + [Fact] + public async Task Validate_DirectoryUsesExecutingShellMachineMode() + { + using var dir = new TempDirectory(); + var broken = Path.Combine(dir.Path, "broken.toml"); + File.WriteAllText(broken, + """ + name = "broken" + + [colors] + literal = "lightyellow3" + """); + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { Output = "json" }; + var command = new ThemeCommand + { + Action = "validate", + Name = dir.Path, + }; + var savedOut = Console.Out; + using var stdout = new StringWriter(); + try + { + Console.SetOut(stdout); + var error = await Assert.ThrowsAsync(() => command.ExecuteAsync(shell, new CommandState(), "", CancellationToken.None)); + + Assert.Contains(broken, error.Message); + Assert.Empty(stdout.ToString()); + } + finally + { + Console.SetOut(savedOut); + } + } + [Fact] public async Task Validate_StrictTreatsWarningsAsErrors() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs index 810f7e21..cc72878b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs @@ -494,7 +494,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co var continueOnError = this.ContinueOnError == true; var partitionKeySegments = ParsePartitionKeySegments(this.PartitionKey); - var errors = new BulkWriteDiagnostics(); + var errors = shell.IsMachineMode ? new BulkWriteDiagnostics() : null; var (successCount, failCount, charge) = await ExecuteImportAsync( shell, filePath, @@ -514,14 +514,14 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co { if (dryRun) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-import-dry-run-success", "count", successCount)); } else if (failCount == 0) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-import-success", "count", successCount, @@ -530,7 +530,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co } else if (successCount > 0) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString( + shell.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString( "command-import-success-partial", "success", successCount, @@ -541,7 +541,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co } else { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString( + shell.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString( "command-import-all-failed", "count", failCount)); @@ -556,7 +556,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co failCount, "total", successCount + failCount); - if (shell.IsMachineMode) + if (errors != null) { message += Environment.NewLine + errors.GetMessage(); } @@ -611,7 +611,7 @@ private static async Task ResolveFormatAsync(string filePath, Impo IReadOnlyList partitionKeyPaths, int concurrency, CommandState commandState, - BulkWriteDiagnostics errors, + BulkWriteDiagnostics? errors, CancellationToken token) { FileStream? stream = null; @@ -662,7 +662,7 @@ private static async Task ResolveFormatAsync(string filePath, Impo errors: errors); } catch (Exception ex) when ((ex is CommandException or JsonException) - && ShellInterpreter.Instance.IsMachineMode && errors.Count > 0) + && errors is { Count: > 0 }) { throw new CommandException("import", ex.Message + Environment.NewLine + errors.GetMessage(), ex); } @@ -718,13 +718,13 @@ void Record((double Charge, string? Error) result) else { failed++; - if (ShellInterpreter.Instance.IsMachineMode) + if (errors != null) { - errors?.Add(result.Error); + errors.Add(result.Error); } else { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, result.Error); + (warningShell ?? ShellInterpreter.Instance).Output.WriteLine(ShellMessageKind.Error, result.Error); } } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs index 298d6cad..8254cc2e 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs @@ -159,7 +159,7 @@ private static async Task WriteItemAsync( int replacedCount = 0; int failCount = 0; double charge = 0.0; - var presentResults = !ShellInterpreter.Instance.IsMachineMode; + var presentResults = !shell.IsMachineMode; var errors = presentResults ? null : new BulkWriteDiagnostics(); var itemIndex = 0; foreach (var element in root.EnumerateArray()) @@ -187,7 +187,7 @@ private static async Task WriteItemAsync( errors?.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", message)); if (presentResults) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, message); + shell.Output.WriteLine(ShellMessageKind.Error, message); } } } @@ -204,7 +204,7 @@ private static async Task WriteItemAsync( errors?.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", message)); if (presentResults) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, message); + shell.Output.WriteLine(ShellMessageKind.Error, message); } } catch (Exception ex) when (ex is JsonException or FormatException or ArgumentException) @@ -219,7 +219,7 @@ private static async Task WriteItemAsync( errors?.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", message)); if (presentResults) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, message); + shell.Output.WriteLine(ShellMessageKind.Error, message); } } } @@ -230,7 +230,7 @@ private static async Task WriteItemAsync( { if ((createdCount + replacedCount) > 0 && failCount == 0) { - ShellInterpreter.Instance.Output.WriteLine( + shell.Output.WriteLine( ShellMessageKind.Information, MessageService.GetArgsString( "command-mkitem-upserted-multiple", @@ -243,7 +243,7 @@ private static async Task WriteItemAsync( } else if ((createdCount + replacedCount) > 0) { - ShellInterpreter.Instance.Output.WriteLine( + shell.Output.WriteLine( ShellMessageKind.Warning, MessageService.GetArgsString( "command-mkitem-upserted-partial", @@ -258,7 +258,7 @@ private static async Task WriteItemAsync( } else { - ShellInterpreter.Instance.Output.WriteLine( + shell.Output.WriteLine( ShellMessageKind.Error, MessageService.GetArgsString( "command-mkitem-upserted-all-failed", @@ -268,7 +268,7 @@ private static async Task WriteItemAsync( } else if (createdCount > 0 && failCount == 0) { - ShellInterpreter.Instance.Output.WriteLine( + shell.Output.WriteLine( ShellMessageKind.Information, MessageService.GetArgsString( "command-mkitem-created-multiple", @@ -279,7 +279,7 @@ private static async Task WriteItemAsync( } else if (createdCount > 0 && failCount > 0) { - ShellInterpreter.Instance.Output.WriteLine( + shell.Output.WriteLine( ShellMessageKind.Warning, MessageService.GetArgsString( "command-mkitem-created-partial", @@ -292,7 +292,7 @@ private static async Task WriteItemAsync( } else { - ShellInterpreter.Instance.Output.WriteLine( + shell.Output.WriteLine( ShellMessageKind.Error, MessageService.GetArgsString( "command-mkitem-created-all-failed", @@ -336,7 +336,7 @@ private static async Task WriteItemAsync( if (result.StatusCode == System.Net.HttpStatusCode.Created) { var key = force ? "command-mkitem-upserted-created" : "command-mkitem-created-success"; - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( key, "charge", result.RequestCharge.ToString("F2"))); @@ -344,7 +344,7 @@ private static async Task WriteItemAsync( } else if (force && result.StatusCode == System.Net.HttpStatusCode.OK) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-mkitem-upserted-replaced", "charge", result.RequestCharge.ToString("F2"))); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs index ee3088a2..27f36e4a 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs @@ -93,7 +93,7 @@ private static async Task ReplaceItemsAsync( && HasUndefinedPartitionKeyComponent(item, partitionKeyPaths)); AddUndefinedPartitionKeyWarning(commandState, undefinedPartitionKeyCount); shell.RenderMutationWarnings(commandState); - return await ReplaceArrayAsync(container, partitionKeyPaths, root, token); + return await ReplaceArrayAsync(shell, container, partitionKeyPaths, root, token); } var undefinedCount = root.ValueKind == JsonValueKind.Object @@ -102,7 +102,7 @@ private static async Task ReplaceItemsAsync( : 0; AddUndefinedPartitionKeyWarning(commandState, undefinedCount); shell.RenderMutationWarnings(commandState); - var singleCharge = await ReplaceOneAsync(container, partitionKeyPaths, root, etag, token, printSuccess: true); + var singleCharge = await ReplaceOneAsync(shell, container, partitionKeyPaths, root, etag, token, printSuccess: true); return new ReplaceSummary(1, 0, singleCharge); } catch (JsonException ex) @@ -111,12 +111,12 @@ private static async Task ReplaceItemsAsync( } } - private static async Task ReplaceArrayAsync(Container container, IReadOnlyList partitionKeyPaths, JsonElement arrayRoot, CancellationToken token) + private static async Task ReplaceArrayAsync(ShellInterpreter shell, Container container, IReadOnlyList partitionKeyPaths, JsonElement arrayRoot, CancellationToken token) { int successCount = 0; int failCount = 0; double charge = 0.0; - var presentResults = !ShellInterpreter.Instance.IsMachineMode; + var presentResults = !shell.IsMachineMode; var errors = presentResults ? null : new BulkWriteDiagnostics(); var itemIndex = 0; @@ -125,7 +125,7 @@ private static async Task ReplaceArrayAsync(Container container, itemIndex++; try { - var itemCharge = await ReplaceOneAsync(container, partitionKeyPaths, element, etag: null, token, printSuccess: false); + var itemCharge = await ReplaceOneAsync(shell, container, partitionKeyPaths, element, etag: null, token, printSuccess: false); charge += itemCharge; successCount++; } @@ -136,7 +136,7 @@ private static async Task ReplaceArrayAsync(Container container, errors?.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", ex.Message)); if (presentResults) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, ex.Message); + shell.Output.WriteLine(ShellMessageKind.Error, ex.Message); } } } @@ -145,15 +145,15 @@ private static async Task ReplaceArrayAsync(Container container, { if (successCount > 0 && failCount == 0) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-replace-success-multiple", "count", successCount, "charge", charge.ToString("F2"))); + shell.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-replace-success-multiple", "count", successCount, "charge", charge.ToString("F2"))); } else if (successCount > 0) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString("command-replace-success-partial", "success", successCount, "failed", failCount, "charge", charge.ToString("F2"))); + shell.Output.WriteLine(ShellMessageKind.Warning, MessageService.GetArgsString("command-replace-success-partial", "success", successCount, "failed", failCount, "charge", charge.ToString("F2"))); } else { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString("command-replace-all-failed", "count", failCount)); + shell.Output.WriteLine(ShellMessageKind.Error, MessageService.GetArgsString("command-replace-all-failed", "count", failCount)); } } @@ -177,7 +177,7 @@ private static async Task ReplaceArrayAsync(Container container, return new ReplaceSummary(successCount, failCount, charge); } - private static async Task ReplaceOneAsync(Container container, IReadOnlyList partitionKeyPaths, JsonElement item, string? etag, CancellationToken token, bool printSuccess) + private static async Task ReplaceOneAsync(ShellInterpreter shell, Container container, IReadOnlyList partitionKeyPaths, JsonElement item, string? etag, CancellationToken token, bool printSuccess) { if (item.ValueKind != JsonValueKind.Object) { @@ -231,7 +231,7 @@ private static async Task ReplaceOneAsync(Container container, IReadOnly if (printSuccess) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-replace-success-single", "charge", response.Headers.RequestCharge.ToString("F2"))); + shell.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString("command-replace-success-single", "charge", response.Headers.RequestCharge.ToString("F2"))); } return response.Headers.RequestCharge; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs index 0cac5bba..d5bff652 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs @@ -49,16 +49,16 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS return action switch { - "current" => Task.FromResult(this.RunCurrent(commandState)), - "list" => Task.FromResult(this.RunList(commandState)), - "show" => Task.FromResult(this.RunShow(commandState)), - "use" or "set" => Task.FromResult(this.RunUse(commandState)), - "load" => Task.FromResult(this.RunLoad(commandState)), - "validate" => Task.FromResult(this.RunValidate(commandState)), - "save" => Task.FromResult(this.RunSave(commandState)), - "edit" => Task.FromResult(this.RunEdit(commandState)), - "open" => Task.FromResult(this.RunOpen(commandState)), - "reload" => Task.FromResult(this.RunReload(commandState)), + "current" => Task.FromResult(this.RunCurrent(shell, commandState)), + "list" => Task.FromResult(this.RunList(shell, commandState)), + "show" => Task.FromResult(this.RunShow(shell, commandState)), + "use" or "set" => Task.FromResult(this.RunUse(shell, commandState)), + "load" => Task.FromResult(this.RunLoad(shell, commandState)), + "validate" => Task.FromResult(this.RunValidate(shell, commandState)), + "save" => Task.FromResult(this.RunSave(shell, commandState)), + "edit" => Task.FromResult(this.RunEdit(shell, commandState)), + "open" => Task.FromResult(this.RunOpen(shell, commandState)), + "reload" => Task.FromResult(this.RunReload(shell, commandState)), _ => throw UnknownAction(action), }; } @@ -143,13 +143,13 @@ private static void LaunchInOsFileBrowser(string target) }); } - private static void RenderThemeSample(ThemeOptions profile, string profileName) + private static void RenderThemeSample(ShellInterpreter shell, ThemeOptions profile, string profileName) { var saved = Theme.Current; try { Theme.Apply(profile); - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-sample-heading", "name", Markup.Escape(profileName.ToLowerInvariant()))); + shell.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-sample-heading", "name", Markup.Escape(profileName.ToLowerInvariant()))); var table = new Table().HideHeaders(); table.AddColumn(string.Empty); @@ -184,7 +184,7 @@ private static void RenderThemeSample(ThemeOptions profile, string profileName) Row(MessageService.GetString("command-theme-role-help-description"), Theme.FormatHelpDescription(MessageService.GetString("command-theme-sample-description"))); Row(MessageService.GetString("command-theme-role-brackets"), string.Concat(Theme.FormatBracket("{", 0), Theme.FormatBracket("[", 1), Theme.FormatBracket("(", 2), Theme.FormatBracket(")", 2), Theme.FormatBracket("]", 1), Theme.FormatBracket("}", 0))); - ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); + shell.Output.Render(ShellMessageKind.Result, table); } finally { @@ -192,15 +192,15 @@ private static void RenderThemeSample(ThemeOptions profile, string profileName) } } - private CommandState RunCurrent(CommandState commandState) + private CommandState RunCurrent(ShellInterpreter shell, CommandState commandState) { var name = ResolveActiveName(); - commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-active", "name", Markup.Escape(name))); + commandState.RenderUser = () => shell.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-active", "name", Markup.Escape(name))); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", id = name, active = true })); return commandState; } - private CommandState RunList(CommandState commandState) + private CommandState RunList(ShellInterpreter shell, CommandState commandState) { var active = ResolveActiveName(); var items = new List>(); @@ -229,14 +229,14 @@ private CommandState RunList(CommandState commandState) { foreach (var (marker, name, source) in rows) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, $" {marker} {Markup.Escape(name)} {Theme.FormatMuted(source)}"); + shell.Output.MarkupLine(ShellMessageKind.Result, $" {marker} {Markup.Escape(name)} {Theme.FormatMuted(source)}"); } }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", values = items })); return commandState; } - private CommandState RunShow(CommandState commandState) + private CommandState RunShow(ShellInterpreter shell, CommandState commandState) { var profileName = string.IsNullOrWhiteSpace(this.Name) ? ResolveActiveName() : this.Name; if (!ThemeProfiles.TryGet(profileName, out var profile)) @@ -244,12 +244,12 @@ private CommandState RunShow(CommandState commandState) throw UnknownTheme(profileName); } - commandState.RenderUser = () => RenderThemeSample(profile, profileName); + commandState.RenderUser = () => RenderThemeSample(shell, profile, profileName); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", id = profileName.ToLowerInvariant(), previewed = true })); return commandState; } - private CommandState RunUse(CommandState commandState) + private CommandState RunUse(ShellInterpreter shell, CommandState commandState) { if (string.IsNullOrWhiteSpace(this.Name)) { @@ -263,12 +263,12 @@ private CommandState RunUse(CommandState commandState) Theme.Apply(profile); var name = this.Name.ToLowerInvariant(); - commandState.RenderUser = () => ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-applied", "name", Markup.Escape(name))); + commandState.RenderUser = () => shell.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-theme-applied", "name", Markup.Escape(name))); commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { type = "theme", id = name, applied = true })); return commandState; } - private CommandState RunLoad(CommandState commandState) + private CommandState RunLoad(ShellInterpreter shell, CommandState commandState) { var requested = string.IsNullOrWhiteSpace(this.Name) ? this.Path : this.Name; if (string.IsNullOrWhiteSpace(requested)) @@ -283,7 +283,7 @@ private CommandState RunLoad(CommandState commandState) { var result = ThemeRegistry.Instance.LoadFile(path); Theme.Apply(result.Options); - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-loaded", "name", Markup.Escape(result.Name), @@ -291,7 +291,7 @@ private CommandState RunLoad(CommandState commandState) Markup.Escape(result.Source))); foreach (var warning in result.Warnings) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); + shell.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -316,7 +316,7 @@ private CommandState RunLoad(CommandState commandState) } } - private CommandState RunSave(CommandState commandState) + private CommandState RunSave(ShellInterpreter shell, CommandState commandState) { if (string.IsNullOrWhiteSpace(this.Name)) { @@ -355,13 +355,13 @@ private CommandState RunSave(CommandState commandState) try { ThemeFile.Save(this.Name, Theme.Current, path); - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-saved", "name", Markup.Escape(this.Name), "path", Markup.Escape(System.IO.Path.GetFullPath(path)))); - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString("command-theme-save-hint-reload", "name", this.Name))); + shell.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString("command-theme-save-hint-reload", "name", this.Name))); commandState.RenderUser = () => { }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { @@ -379,37 +379,37 @@ private CommandState RunSave(CommandState commandState) } } - private CommandState RunValidate(CommandState commandState) + private CommandState RunValidate(ShellInterpreter shell, CommandState commandState) { var requested = string.IsNullOrWhiteSpace(this.Name) ? this.Path : this.Name; if (string.IsNullOrWhiteSpace(requested)) { - return this.RunValidateDirectory(commandState, ThemeFile.DefaultUserThemesDirectory()); + return this.RunValidateDirectory(shell, commandState, ThemeFile.DefaultUserThemesDirectory()); } if (Directory.Exists(requested)) { - return this.RunValidateDirectory(commandState, requested); + return this.RunValidateDirectory(shell, commandState, requested); } var path = ResolveThemePath(requested); if (Directory.Exists(path)) { - return this.RunValidateDirectory(commandState, path); + return this.RunValidateDirectory(shell, commandState, path); } - return this.RunValidateFile(commandState, path); + return this.RunValidateFile(shell, commandState, path); } - private CommandState RunValidateFile(CommandState commandState, string path) + private CommandState RunValidateFile(ShellInterpreter shell, CommandState commandState, string path) { try { var result = ThemeRegistry.Instance.ValidateFile(path); var strictFailure = this.Strict && result.Warnings.Count > 0; - if (!ShellInterpreter.Instance.IsMachineMode || !strictFailure) + if (!shell.IsMachineMode || !strictFailure) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-validated", "name", Markup.Escape(result.Name), @@ -417,7 +417,7 @@ private CommandState RunValidateFile(CommandState commandState, string path) Markup.Escape(result.Source))); foreach (var warning in result.Warnings) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); + shell.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } } @@ -429,7 +429,7 @@ private CommandState RunValidateFile(CommandState commandState, string path) result.Name, "count", result.Warnings.Count); - if (ShellInterpreter.Instance.IsMachineMode) + if (shell.IsMachineMode) { strictMessage += Environment.NewLine + result.Source + ": " + string.Join(Environment.NewLine, result.Warnings); } @@ -460,16 +460,16 @@ private CommandState RunValidateFile(CommandState commandState, string path) } } - private CommandState RunValidateDirectory(CommandState commandState, string directory) + private CommandState RunValidateDirectory(ShellInterpreter shell, CommandState commandState, string directory) { var files = ThemeFile.EnumerateThemeFiles(directory); - var presentResults = !ShellInterpreter.Instance.IsMachineMode; + var presentResults = !shell.IsMachineMode; if (files.Length == 0) { var emptyMessage = MessageService.GetArgsString("command-theme-validate-no-files", "directory", directory); if (presentResults) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(emptyMessage)); + shell.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(emptyMessage)); } commandState.RenderUser = () => { }; @@ -512,7 +512,7 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire validationErrors.Add(result.Source + ": " + string.Join(Environment.NewLine, result.Warnings)); if (presentResults) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + shell.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); } } else @@ -520,7 +520,7 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire validCount++; if (presentResults) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, $" {Theme.FormatHelpAccent("\u2713")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); + shell.Output.MarkupLine(ShellMessageKind.Information, $" {Theme.FormatHelpAccent("\u2713")} {Markup.Escape(result.Name)} {Theme.FormatMuted("(" + System.IO.Path.GetFileName(file) + ")")}"); } } @@ -528,7 +528,7 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire { foreach (var warning in result.Warnings) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); + shell.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); } } else @@ -544,8 +544,8 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire validationErrors.Add(file + ": " + ex.Message); if (presentResults) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(System.IO.Path.GetFileName(file))}"); - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Error, " " + Theme.FormatError(ex.Message)); + shell.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(System.IO.Path.GetFileName(file))}"); + shell.Output.MarkupLine(ShellMessageKind.Error, " " + Theme.FormatError(ex.Message)); } } @@ -572,12 +572,12 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire foreach (var warning in pendingWarnings) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); + shell.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); } if (presentResults) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-validate-summary", "valid", validCount, @@ -600,7 +600,7 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire return commandState; } - private CommandState RunOpen(CommandState commandState) + private CommandState RunOpen(ShellInterpreter shell, CommandState commandState) { // 'theme open' always targets the user themes directory. We deliberately // do not accept a name or path argument here: handing arbitrary paths to @@ -626,7 +626,7 @@ private CommandState RunOpen(CommandState commandState) throw new CommandException("theme", message, ex); } - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-opened", "path", Markup.Escape(targetPath))); @@ -636,7 +636,7 @@ private CommandState RunOpen(CommandState commandState) return commandState; } - private CommandState RunEdit(CommandState commandState) + private CommandState RunEdit(ShellInterpreter shell, CommandState commandState) { // Resolve target. Empty -> active theme; otherwise treat as theme name // or path (same rules as load/validate). @@ -704,7 +704,7 @@ private CommandState RunEdit(CommandState commandState) } targetPath = seedPath; - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-edit-seeded", "name", Markup.Escape(registration.Name), @@ -725,7 +725,7 @@ private CommandState RunEdit(CommandState commandState) throw new CommandException("theme", message); } - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-theme-edit-launching", "path", targetPath!, @@ -779,7 +779,7 @@ private CommandState RunEdit(CommandState commandState) // Re-scan the directory too so other files stay in sync with disk. registry.LoadFromDirectory(ThemeFile.DefaultUserThemesDirectory()); - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-edit-applied", "name", Markup.Escape(result.Name), @@ -788,7 +788,7 @@ private CommandState RunEdit(CommandState commandState) foreach (var warning in result.Warnings) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); + shell.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -814,14 +814,14 @@ private CommandState RunEdit(CommandState commandState) } } - private CommandState RunReload(CommandState commandState) + private CommandState RunReload(ShellInterpreter shell, CommandState commandState) { var registry = ThemeRegistry.Instance; registry.ResetToBuiltIns(); var directory = ThemeFile.DefaultUserThemesDirectory(); var loaded = registry.LoadFromDirectory(directory); - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-reloaded", "count", loaded, @@ -829,7 +829,7 @@ private CommandState RunReload(CommandState commandState) Markup.Escape(directory))); foreach (var warning in registry.Warnings) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); + shell.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs index 72ffb1c8..63eef542 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs @@ -209,6 +209,9 @@ private IAnsiConsole GetConsole(ShellMessageKind kind) ColorSystem = ColorSystemSupport.NoColors, Interactive = InteractionSupport.No, Out = new AnsiConsoleOutput(writer), + + // CI enrichers (e.g. GitHub Actions) would otherwise re-enable ANSI escapes. + Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }, }); } From 1b660ca0a9b92790b63f1d68fb336c25b3147f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Mon, 19 Oct 2026 14:04:25 +0200 Subject: [PATCH 07/15] Return client bucket selection and honor interactive 2> for diagnostics Client bucket commands now return a clientBucket result for machine mode. Outside machine mode, ShellOutput routes warnings and errors to an active 2>/2>> redirect while keeping confirmation prompts on the terminal. --- CHANGELOG.md | 3 +- .../CommandTests/BucketCommandTests.cs | 6 +++ .../Parser/CommandStatementTests.cs | 48 +++++++++++++++++++ .../BucketCommand.cs | 9 +++- .../OutputPolicy.cs | 6 ++- .../ShellInterpreter.cs | 3 +- .../ShellOutput.cs | 10 ++-- docs/navigation.md | 2 + 8 files changed, 79 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 428d6ba9..ed2dde26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,9 @@ - Report `theme`, `help`, and `edit` failures once, including a structured error on stderr in machine mode instead of an unexplained failure exit code. - Include failing file paths and actionable diagnostics in machine-mode theme validation errors, and keep required script-editor confirmation instructions visible with `--quiet`. - Keep quiet command echoes in an existing editor's Up/Down history without printing them. -- Preserve per-item failure diagnostics in machine-mode import, bulk insert/upsert, and replacement errors, listing up to 100 items and reporting how many more were omitted. Verbose output-formatting and file-redirection failures also retain structured stderr output and `2>` redirection, and `2>`/`2>>` now also capture warnings and required instructions from successful commands. +- Preserve per-item failure diagnostics in machine-mode import, bulk insert/upsert, and replacement errors, listing up to 100 items and reporting how many more were omitted. Verbose output-formatting and file-redirection failures also retain structured stderr output and `2>` redirection, and `2>`/`2>>` now also capture warnings and required instructions from successful commands. Interactive `2>`/`2>>` now also capture command warnings and errors, while confirmation prompts stay on the terminal. - Keep CLI inferred-key warnings visible before writes when the MCP listener is enabled; MCP calls continue to return warnings in their responses. +- `bucket`, `bucket `, and `bucket 0` return the current client bucket selection as a structured `clientBucket` result in machine mode. ### Improvements diff --git a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs index d32f97b0..f1346b49 100644 --- a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs @@ -4,8 +4,10 @@ namespace CosmosShell.Tests.CommandTests; +using System.Text.Json; using Azure.Data.Cosmos.Shell.Commands; using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Parser; using Azure.Data.Cosmos.Shell.States; using Azure.Data.Cosmos.Shell.Util; using Microsoft.Azure.Cosmos; @@ -68,6 +70,7 @@ public async Task ExecuteAsync_InDatabase_SetsBucket() Assert.False(state.IsError); Assert.Equal(3, client.ClientOptions.ThroughputBucket); + Assert.Equal(3, Assert.IsType(state.Result).Value.GetProperty("clientBucket").GetInt32()); } [Fact] @@ -83,12 +86,14 @@ public async Task ExecuteAsync_InDatabase_ZeroResetsBucket() Assert.False(state.IsError); Assert.Null(client.ClientOptions.ThroughputBucket); + Assert.Equal(JsonValueKind.Null, Assert.IsType(state.Result).Value.GetProperty("clientBucket").ValueKind); } [Fact] public async Task ExecuteAsync_InContainer_NoArgs_ShowsCurrent() { var client = CreateTestClient(); + client.ClientOptions.ThroughputBucket = 2; using var shell = ShellInterpreter.CreateInstance(); shell.State = new ContainerState("TestContainer", "TestDatabase", client); var command = new BucketCommand(); @@ -96,6 +101,7 @@ public async Task ExecuteAsync_InContainer_NoArgs_ShowsCurrent() var state = await command.ExecuteAsync(shell, new CommandState(), "bucket", CancellationToken.None); Assert.False(state.IsError); + Assert.Equal(2, Assert.IsType(state.Result).Value.GetProperty("clientBucket").GetInt32()); } [Fact] diff --git a/CosmosDBShell.Tests/Parser/CommandStatementTests.cs b/CosmosDBShell.Tests/Parser/CommandStatementTests.cs index 3bffbd13..355b14ae 100644 --- a/CosmosDBShell.Tests/Parser/CommandStatementTests.cs +++ b/CosmosDBShell.Tests/Parser/CommandStatementTests.cs @@ -134,6 +134,18 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS } } + [CosmosCommand("interactive-diagnostic-test")] + internal sealed class InteractiveDiagnosticTestCommand : CosmosCommand + { + public override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + shell.Output.WriteLine(ShellMessageKind.Warning, "first warning"); + shell.Output.MarkupLine(ShellMessageKind.Error, "second [bold]error[/]"); + shell.Output.WriteLine(ShellMessageKind.RequiredInstruction, "terminal prompt"); + return Task.FromResult(commandState); + } + } + [CosmosCommand("warning-false-test")] internal sealed class WarningFalseTestCommand : CosmosCommand { @@ -614,6 +626,42 @@ await File.WriteAllTextAsync( } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Interactive_ShellOutputDiagnosticsHonorErrorRedirect(bool append) + { + using var shell = ShellInterpreter.CreateInstance(); + Assert.True(CommandFactory.TryCreateFactory(typeof(InteractiveDiagnosticTestCommand), out var factory)); + shell.App.Commands["interactive-diagnostic-test"] = factory; + shell.Options = new Program.CosmosShellOptions(); + Assert.False(shell.IsMachineMode); + var script = Path.GetTempFileName(); + var stderr = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(stderr, "existing" + Environment.NewLine, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync( + script, + $"interactive-diagnostic-test {(append ? "2>>" : "2>")} {ShellLiteral.Quote(stderr.Replace('\\', '/'))}", + TestContext.Current.CancellationToken); + var command = new CommandStatement(new Token(TokenType.Identifier, script, 0, script.Length)); + + var result = await command.RunScriptAsync(shell, new CommandState(), TestContext.Current.CancellationToken); + + Assert.False(result.IsError, (result as ErrorCommandState)?.Exception.ToString()); + var expected = $"first warning{Environment.NewLine}second error{Environment.NewLine}"; + Assert.Equal( + (append ? "existing" + Environment.NewLine : string.Empty) + expected, + await File.ReadAllTextAsync(stderr, TestContext.Current.CancellationToken)); + } + finally + { + File.Delete(script); + File.Delete(stderr); + } + } + [Fact] public async Task MachineMode_ErrorAfterShellOutputWarningAppendsToErrorRedirect() { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs index 843f16d3..187092cf 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs @@ -267,7 +267,14 @@ private CommandState RunClientCommand(CosmosClient client) WriteClientSelection(client.ClientOptions.ThroughputBucket); } - return new CommandState(); + return new CommandState + { + Result = new ShellJson(JsonSerializer.SerializeToElement(new JsonObject + { + ["clientBucket"] = client.ClientOptions.ThroughputBucket, + })), + RenderUser = () => { }, + }; } private async Task ExecuteServerAsync(ConnectedState state, ShellInterpreter shell, string databaseName, string? containerName, CancellationToken token) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs index e86d6f4e..b778ad9d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs @@ -24,8 +24,10 @@ internal static bool ShouldPresent(ShellMessageKind kind, bool quiet, bool machi _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; - internal static bool UseStandardError(ShellMessageKind kind, bool machineMode) - => machineMode && kind is ShellMessageKind.Warning or ShellMessageKind.Error or ShellMessageKind.RequiredInstruction; + // Interactive confirmation prompts stay on the terminal even when 2> is active. + internal static bool UseStandardError(ShellMessageKind kind, bool machineMode, bool errorRedirected = false) + => (machineMode && kind is ShellMessageKind.Warning or ShellMessageKind.Error or ShellMessageKind.RequiredInstruction) + || (errorRedirected && kind is ShellMessageKind.Warning or ShellMessageKind.Error); /// /// Determines whether the given format is a structured, machine-consumable format. diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 4d32b045..0d03a922 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -115,7 +115,8 @@ internal ShellInterpreter(string? configPath = null) this.Output = new ShellOutput( () => this.Options?.Quiet == true, () => this.IsMachineMode, - standardError: () => this.ErrOutRedirect != null ? this.errorRedirectWriter : Console.Error); + standardError: () => this.ErrOutRedirect != null ? this.errorRedirectWriter : Console.Error, + errorRedirected: () => this.ErrOutRedirect != null); // editor.KeyBindings.Add(ConsoleKey.Escape); // TODO: Support selection commands? diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs index 63eef542..cf8cc3ef 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs @@ -22,7 +22,8 @@ internal sealed class ShellOutput( Func machineMode, Func? standardOutput = null, Func? standardError = null, - Func? console = null) + Func? console = null, + Func? errorRedirected = null) { private readonly object consoleCacheLock = new(); @@ -185,14 +186,17 @@ internal void WriteException(ShellMessageKind kind, Exception exception, Excepti private bool ShouldPresent(ShellMessageKind kind) => OutputPolicy.ShouldPresent(kind, quiet(), machineMode()); + private bool UseStandardError(ShellMessageKind kind) + => OutputPolicy.UseStandardError(kind, machineMode(), errorRedirected?.Invoke() == true); + private TextWriter GetWriter(ShellMessageKind kind) - => OutputPolicy.UseStandardError(kind, machineMode()) + => this.UseStandardError(kind) ? standardError?.Invoke() ?? Console.Error : standardOutput?.Invoke() ?? Console.Out; private IAnsiConsole GetConsole(ShellMessageKind kind) { - if (!machineMode() && !OutputPolicy.UseStandardError(kind, machineMode())) + if (!machineMode() && !this.UseStandardError(kind)) { return console?.Invoke() ?? AnsiConsole.Console; } diff --git a/docs/navigation.md b/docs/navigation.md index b40f3dfe..0bd5de1a 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -318,6 +318,8 @@ imports identify source lines, and item-array writes identify 1-based item posit Up to 100 item diagnostics are listed, followed by a count of any omitted ones. `--verbose` does not replace machine-mode errors with raw stack traces; shell `2>` and `2>>` redirection still applies and also captures warnings and required instructions. +Outside machine mode, `2>` and `2>>` capture warnings and errors, while confirmation +prompts and other required instructions stay on the terminal. Bare piped stdin (for example `echo "..." | cosmosdbshell`) is **not** implicitly machine mode; pass `-c`, `--output json`, or `--quiet` to opt into structured output. From b18b3c1f47b9c523dd8e5b5ded30b4ec9f53ed9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Mon, 19 Oct 2026 14:14:49 +0200 Subject: [PATCH 08/15] Classify info RBAC permission diagnostics as errors Interactive info 2> now captures the permission failure and its remediation text. --- .../Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs index fe493f69..5ccd0402 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs @@ -196,17 +196,17 @@ private static void AskForRBacPermissions(string principalId, string request, st var line = lines[i]; if (i == 0) { - ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, indent + Theme.FormatError(MessageService.GetString("error")) + " "); - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, line); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Error, indent + Theme.FormatError(MessageService.GetString("error")) + " "); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, line); } else if (line.Length == 0) { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error); } else { - ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Result, indent); - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, line); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Error, indent); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, line); } } } From ce37d9bcf5031dc76370d95112f29d6b9b3d37b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Mon, 19 Oct 2026 14:18:07 +0200 Subject: [PATCH 09/15] Address code-quality findings in new tests Fault the bad-status stream mock instead of returning an undisposed ResponseMessage, and use Path.Join for the theme test path. --- CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs | 3 ++- CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs index 24a812f1..137f5672 100644 --- a/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs +++ b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs @@ -276,7 +276,8 @@ static Task WriteStream(Stream body) using var document = JsonDocument.Parse(body); if (document.RootElement.GetProperty("id").GetString() == "bad-status") { - return Task.FromResult(new ResponseMessage(HttpStatusCode.BadRequest)); + return Task.FromException( + new CosmosException("bad status", HttpStatusCode.BadRequest, 0, "test", 0)); } return Task.FromException( diff --git a/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs index bf45bc0d..581c0ad2 100644 --- a/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs @@ -90,7 +90,7 @@ public async Task Validate_ScansDirectoryAndReportsCounts() public async Task Validate_DirectoryUsesExecutingShellMachineMode() { using var dir = new TempDirectory(); - var broken = Path.Combine(dir.Path, "broken.toml"); + var broken = Path.Join(dir.Path, "broken.toml"); File.WriteAllText(broken, """ name = "broken" From 967b637750e9a460ae19d741620b5fff8cde124b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 5 Nov 2026 10:53:49 +0100 Subject: [PATCH 10/15] Defer changelog updates in #254 until release preparation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 7 ------- 1 file changed, 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2dde26..f0df9a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,6 @@ ### Breaking changes - `print ` now parses the key with the same typed syntax as `patch`, so `print item-1 680` reads numeric key `680` instead of string `"680"`. To address a string key that looks like a number, boolean, null, or JSON array, quote it as a JSON string: `print item-1 '"680"'`. ([#244](https://github.com/Azure/CosmosDBShell/issues/244)) -- Machine-mode `watch` now requires a positive `--max` and returns collected changes as a bounded final result. Interactive watches can still follow until Ctrl+C. ### New features @@ -20,17 +19,11 @@ - `sproc exec` now returns `null` for a stored procedure that completes without setting a response body, instead of reporting a failure after the procedure's writes have committed. - `print` now reads items with typed scalar and complete hierarchical partition keys, for example `print customer-1001 '[1001,"customer-1001"]'`. Invalid keys are distinguished from missing items. Shared argument parsing rejects object/nested-array components and non-finite numbers instead of coercing them. ([#244](https://github.com/Azure/CosmosDBShell/issues/244)) - Avoid accessing an expired HTTP request during MCP session teardown, preventing disposal errors while releasing location subscriptions and legacy SSE admission state. ([#249](https://github.com/Azure/CosmosDBShell/issues/249)) -- Report `theme`, `help`, and `edit` failures once, including a structured error on stderr in machine mode instead of an unexplained failure exit code. -- Include failing file paths and actionable diagnostics in machine-mode theme validation errors, and keep required script-editor confirmation instructions visible with `--quiet`. -- Keep quiet command echoes in an existing editor's Up/Down history without printing them. -- Preserve per-item failure diagnostics in machine-mode import, bulk insert/upsert, and replacement errors, listing up to 100 items and reporting how many more were omitted. Verbose output-formatting and file-redirection failures also retain structured stderr output and `2>` redirection, and `2>`/`2>>` now also capture warnings and required instructions from successful commands. Interactive `2>`/`2>>` now also capture command warnings and errors, while confirmation prompts stay on the terminal. - Keep CLI inferred-key warnings visible before writes when the MCP listener is enabled; MCP calls continue to return warnings in their responses. -- `bucket`, `bucket `, and `bucket 0` return the current client bucket selection as a structured `clientBucket` result in machine mode. ### Improvements - `import` now streams documents with up to 16 concurrent writes by default. Use `--concurrency` to tune the limit or `--concurrency=1` for sequential, file-order writes. On a failure, imports stop scheduling new writes and wait for in-flight writes; `--continue-on-error` continues after per-item write failures. -- Apply a consistent output policy: `--quiet` suppresses informational messages, progress, banners, and command echoes while preserving results, warnings, errors, and required authentication or confirmation instructions. Machine-mode stdout carries only command results; diagnostics use stderr without ANSI styling. ## 1.1.280 — 2026-10-09 From 2881cd496713cc13c70257efc5d1628c12961115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 5 Nov 2026 12:01:40 +0100 Subject: [PATCH 11/15] Preserve redirected batch failure diagnostics in #254 Use the executing shell for batch renderers and avoid rendering an error a second time after PrintState reports it to stderr redirection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CommandTests/BatchCommandTests.cs | 7 ++- .../CommandTests/CommandStatusOutputTests.cs | 60 +++++++++++++++++++ .../BatchCommand.cs | 4 +- .../BatchExecutor.cs | 15 +++-- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/BatchCommandTests.cs b/CosmosDBShell.Tests/CommandTests/BatchCommandTests.cs index 3850dce3..2e438b4f 100644 --- a/CosmosDBShell.Tests/CommandTests/BatchCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/BatchCommandTests.cs @@ -249,7 +249,7 @@ await Assert.ThrowsAsync( public async Task Execute_WithoutOperations_Throws() { await Assert.ThrowsAsync( - () => BatchExecutor.ExecuteAsync("batch", null!, default, [], CancellationToken.None)); + () => BatchExecutor.ExecuteAsync(null!, "batch", null!, default, [], CancellationToken.None)); } [Fact] @@ -259,7 +259,7 @@ public async Task Execute_OverMaximumOperations_Throws() var operations = Enumerable.Repeat(operation, BatchExecutor.MaxOperations + 1).ToArray(); await Assert.ThrowsAsync( - () => BatchExecutor.ExecuteAsync("batch", null!, default, operations, CancellationToken.None)); + () => BatchExecutor.ExecuteAsync(null!, "batch", null!, default, operations, CancellationToken.None)); } [Fact] @@ -271,7 +271,8 @@ public void CreateResultState_PreservesBatchResultAndRequestCharge() requestCharge = 4.25, }); - var state = BatchExecutor.CreateResultState(summary, "Batch committed.", 4.25); + using var shell = ShellInterpreter.CreateInstance(); + var state = BatchExecutor.CreateResultState(shell, summary, "Batch committed.", 4.25); Assert.Equal(4.25, state.RequestCharge); var result = Assert.IsType(state.Result).Value; diff --git a/CosmosDBShell.Tests/CommandTests/CommandStatusOutputTests.cs b/CosmosDBShell.Tests/CommandTests/CommandStatusOutputTests.cs index 081ba6ef..1efc1553 100644 --- a/CosmosDBShell.Tests/CommandTests/CommandStatusOutputTests.cs +++ b/CosmosDBShell.Tests/CommandTests/CommandStatusOutputTests.cs @@ -18,6 +18,66 @@ namespace CosmosShell.Tests.CommandTests; [Collection(ConsoleOutputTestCollection.Name)] public class CommandStatusOutputTests { + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task BatchFailure_ErrorRedirectPreservesSingleDiagnostic(bool append, bool positional) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { Output = "user", Verbose = !positional }; + var file = Path.GetTempFileName(); + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + await File.WriteAllTextAsync(file, "existing\n", TestContext.Current.CancellationToken); + shell.ErrOutRedirect = file; + shell.AppendErrRedirection = append; + var container = Substitute.For(); + var batch = Substitute.For(); + using var response = Substitute.For(); + response.StatusCode.Returns(HttpStatusCode.Conflict); + response.IsSuccessStatusCode.Returns(false); + response.RequestCharge.Returns(1.25); + container.CreateTransactionalBatch(Arg.Any()).Returns(batch); + batch.ExecuteAsync(Arg.Any()).Returns(response); + var operations = BatchOperationParser.Parse("batch", """{"op":"delete","id":"missing"}"""); + var state = Assert.IsType(await BatchExecutor.ExecuteAsync( + shell, "batch", container, new PartitionKey("pk"), operations, TestContext.Current.CancellationToken)); + if (positional) + { + state.Exception = new PositionalException("batch.csh", state.Exception, 4, 7, "batch execute"); + } + + shell.PrintState(state); + + var content = await File.ReadAllTextAsync(file, TestContext.Current.CancellationToken); + Assert.Empty(output.ToString()); + Assert.Equal(append, content.StartsWith("existing", StringComparison.Ordinal)); + if (positional) + { + Assert.Contains("batch.csh:4:7", content); + } + else + { + Assert.Contains("CommandException", content); + } + + var beforeRenderer = content; + state.RenderUser!(); + Assert.Equal(beforeRenderer, await File.ReadAllTextAsync(file, TestContext.Current.CancellationToken)); + Assert.Empty(output.ToString()); + } + finally + { + Console.SetOut(originalOut); + File.Delete(file); + } + } + [Theory] [InlineData(".json", false)] [InlineData(".csv", false)] diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs index b1a7bd9e..0a31c030 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs @@ -219,7 +219,7 @@ private async Task RunAsync(ShellInterpreter shell, CommandState c var partitionKey = ParsePartitionKey(this.PartitionKeyArgument); try { - return await BatchExecutor.ExecuteAsync("batch", container, partitionKey, specs, token); + return await BatchExecutor.ExecuteAsync(shell, "batch", container, partitionKey, specs, token); } catch (CosmosException ce) { @@ -319,7 +319,7 @@ private async Task ExecuteBatchAsync(ShellInterpreter shell, Cance try { - var result = await BatchExecutor.ExecuteAsync("batch", container, batch.PartitionKey, batch.Operations, token); + var result = await BatchExecutor.ExecuteAsync(shell, "batch", container, batch.PartitionKey, batch.Operations, token); shell.CurrentBatch = null; return result; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs index addda5bc..34a6f28c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchExecutor.cs @@ -17,6 +17,7 @@ internal static class BatchExecutor internal const int MaxOperations = 100; public static async Task ExecuteAsync( + ShellInterpreter shell, string commandName, Container container, PartitionKey partitionKey, @@ -74,7 +75,7 @@ public static async Task ExecuteAsync( operations.Count, "charge", response.RequestCharge.ToString("F2", CultureInfo.InvariantCulture)); - return CreateResultState(summary, successMessage, response.RequestCharge); + return CreateResultState(shell, summary, successMessage, response.RequestCharge); } var errorMessage = MessageService.GetArgsString( @@ -92,18 +93,24 @@ public static async Task ExecuteAsync( { RequestCharge = response.RequestCharge, }; - errorState.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, errorMessage); + errorState.RenderUser = () => + { + if (shell.ErrOutRedirect == null) + { + shell.Output.WriteLine(ShellMessageKind.Error, errorMessage); + } + }; return errorState; } - internal static CommandState CreateResultState(JsonElement summary, string message, double requestCharge) + internal static CommandState CreateResultState(ShellInterpreter shell, JsonElement summary, string message, double requestCharge) { var state = new CommandState { Result = new ShellJson(summary), RequestCharge = requestCharge, }; - state.RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, message); + state.RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, message); return state; } From 3dda77455939a040783c799410b3697f4594a7b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 5 Nov 2026 12:13:46 +0100 Subject: [PATCH 12/15] Localize bulk item diagnostic prefixes in #254 Format insert, upsert, and replacement item positions through the existing localization catalog entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs | 2 +- .../Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs | 2 +- CosmosDBShell/lang/en.ftl | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs index 638db6ae..5892fcda 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs @@ -172,7 +172,7 @@ void RecordError(string message) { if (errors != null) { - errors.Add($"Item {itemIndex}: {message}"); + errors.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", message)); } else { diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs index 7add20c7..ff960d8e 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs @@ -132,7 +132,7 @@ private static async Task ReplaceArrayAsync(ShellInterpreter she failCount++; if (errors != null) { - errors.Add($"Item {itemIndex}: {ex.Message}"); + errors.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", ex.Message)); } else { diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index a194ff12..2534f54f 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -180,6 +180,7 @@ error-no_input_data = No input data. error-bulk-item = Item { $index }: { $message } error-bulk-diagnostic_truncated = [diagnostic truncated] error-bulk-diagnostics_omitted = { $count } additional item diagnostics omitted. +error-bulk-item = Item { $index }: { $message } error-argument_parse_redirect = Invalid redirection error-param_parse = Invalid value for '{ $name }' valid values: { $values } error-too_many_arguments = Too many arguments ({$expected} expected) From 40202bbdedd3a1f938ed8ed4185d50bc47acabc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 5 Nov 2026 12:15:03 +0100 Subject: [PATCH 13/15] Reuse the existing bulk diagnostic localization entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CosmosDBShell/lang/en.ftl | 1 - 1 file changed, 1 deletion(-) diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 2534f54f..a194ff12 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -180,7 +180,6 @@ error-no_input_data = No input data. error-bulk-item = Item { $index }: { $message } error-bulk-diagnostic_truncated = [diagnostic truncated] error-bulk-diagnostics_omitted = { $count } additional item diagnostics omitted. -error-bulk-item = Item { $index }: { $message } error-argument_parse_redirect = Invalid redirection error-param_parse = Invalid value for '{ $name }' valid values: { $values } error-too_many_arguments = Too many arguments ({$expected} expected) From fa626a44760207f8e7b9addd60b5ce1247c63518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 5 Nov 2026 12:30:31 +0100 Subject: [PATCH 14/15] Use executing shell output for bucket selection in #254 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CommandTests/BucketCommandTests.cs | 61 +++++++++++++++++++ .../BucketCommand.cs | 40 ++++++------ 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs index f1346b49..aa1d7c02 100644 --- a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs @@ -11,13 +11,74 @@ namespace CosmosShell.Tests.CommandTests; using Azure.Data.Cosmos.Shell.States; using Azure.Data.Cosmos.Shell.Util; using Microsoft.Azure.Cosmos; +using Spectre.Console; /// /// Unit tests for . Covers the pure validation helper and /// the offline state visitors that do not require a live Cosmos DB connection. /// +[Collection(ConsoleOutputTestCollection.Name)] public class BucketCommandTests { + [Theory] + [InlineData(null, false)] + [InlineData("3", false)] + [InlineData("0", false)] + [InlineData(null, true)] + [InlineData("3", true)] + [InlineData("0", true)] + public async Task ClientSelection_UsesExecutingShellOutput(string? action, bool machineMode) + { + using var shell = ShellInterpreter.CreateInstance(); + using var client = CreateTestClient(); + client.ClientOptions.ThroughputBucket = 2; + shell.State = new DatabaseState("TestDatabase", client); + shell.Options = new Program.CosmosShellOptions { Output = machineMode ? "json" : "user", Quiet = machineMode }; + var singletonOptions = ShellInterpreter.Instance.Options; + var originalOut = Console.Out; + var originalConsole = AnsiConsole.Console; + using var output = new StringWriter(); + try + { + ShellInterpreter.Instance.Options = new Program.CosmosShellOptions { Output = machineMode ? "user" : "json", Quiet = !machineMode }; + Console.SetOut(output); + AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings + { + Ansi = AnsiSupport.No, + ColorSystem = ColorSystemSupport.NoColors, + Out = new AnsiConsoleOutput(output), + }); + var state = await new BucketCommand { Action = action }.ExecuteAsync( + shell, new CommandState(), "bucket", TestContext.Current.CancellationToken); + shell.PrintState(state); + + if (machineMode) + { + using var result = JsonDocument.Parse(output.ToString()); + var selection = result.RootElement.GetProperty("clientBucket"); + if (action == "0") + { + Assert.Equal(JsonValueKind.Null, selection.ValueKind); + } + else + { + Assert.Equal(action == "3" ? 3 : 2, selection.GetInt32()); + } + } + else + { + Assert.NotEmpty(output.ToString()); + Assert.DoesNotContain("clientBucket", output.ToString()); + } + } + finally + { + Console.SetOut(originalOut); + AnsiConsole.Console = originalConsole; + ShellInterpreter.Instance.Options = singletonOptions; + } + } + [Theory] [InlineData(0)] [InlineData(1)] diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs index 8768239e..4ac44a21 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs @@ -100,7 +100,7 @@ async Task IStateVisitor.VisitData return await this.ExecuteServerAsync(state, shell, database, NormalizeOption(this.Container), token); } - return this.RunClientCommand(state.Client); + return this.RunClientCommand(shell, state.Client); } async Task IStateVisitor.VisitContainerStateAsync(ContainerState state, ShellInterpreter shell, CancellationToken token) @@ -112,7 +112,7 @@ async Task IStateVisitor.VisitCont return await this.ExecuteServerAsync(state, shell, database, containerName, token); } - return this.RunClientCommand(state.Client); + return this.RunClientCommand(shell, state.Client); } // Treat an explicitly empty option (e.g. --container "") as "not provided" so it @@ -134,7 +134,7 @@ private static bool ConfirmWrite(ShellInterpreter shell, bool yes, string summar return ShellInterpreter.Confirm("command-bucket-confirm", shell); } - private static CommandState BuildShowResult(ConnectedState state, ThroughputBucketsView view) + private static CommandState BuildShowResult(ShellInterpreter shell, ConnectedState state, ThroughputBucketsView view) { var bucketsArray = new JsonArray(); foreach (var bucket in view.Buckets) @@ -169,7 +169,7 @@ private static CommandState BuildShowResult(ConnectedState state, ThroughputBuck { if (view.Buckets.Count == 0) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-bucket-no_limits", "resource", Markup.Escape(view.ResourceName))); + shell.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-bucket-no_limits", "resource", Markup.Escape(view.ResourceName))); } else { @@ -183,23 +183,23 @@ private static CommandState BuildShowResult(ConnectedState state, ThroughputBuck Theme.FormatTableValue(bucket.MaxThroughputPercentage.ToString(CultureInfo.InvariantCulture) + "%")); } - ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); + shell.Output.Render(ShellMessageKind.Result, table); } - WriteClientSelection(clientBucket); + WriteClientSelection(shell, clientBucket); }, }; } - private static void WriteClientSelection(int? clientBucket) + private static void WriteClientSelection(ShellInterpreter shell, int? clientBucket) { if (clientBucket.HasValue) { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-current", new Dictionary { { "bucket", Theme.FormatTableValue(clientBucket.Value.ToString(CultureInfo.InvariantCulture)) } })); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-current", new Dictionary { { "bucket", Theme.FormatTableValue(clientBucket.Value.ToString(CultureInfo.InvariantCulture)) } })); } else { - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-no_bucket")); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-no_bucket")); } } @@ -234,7 +234,7 @@ private BucketActionKind Classify() private bool IsServerAction() => this.Classify() is BucketActionKind.ServerShow or BucketActionKind.ServerSet or BucketActionKind.ServerClear; - private CommandState RunClientCommand(CosmosClient client) + private CommandState RunClientCommand(ShellInterpreter shell, CosmosClient client) { if (this.Id.HasValue || this.Percent.HasValue) { @@ -254,17 +254,17 @@ private CommandState RunClientCommand(CosmosClient client) if (bucket == 0) { client.ClientOptions.ThroughputBucket = null; - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-reset_bucket")); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-reset_bucket")); } else { client.ClientOptions.ThroughputBucket = bucket; - ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-switched_bucket", new Dictionary { { "bucket", Theme.FormatTableValue(bucket.ToString(CultureInfo.InvariantCulture)) } })); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-switched_bucket", new Dictionary { { "bucket", Theme.FormatTableValue(bucket.ToString(CultureInfo.InvariantCulture)) } })); } } else { - WriteClientSelection(client.ClientOptions.ThroughputBucket); + WriteClientSelection(shell, client.ClientOptions.ThroughputBucket); } return new CommandState @@ -304,7 +304,7 @@ private async Task ExecuteServerAsync(ConnectedState state, ShellI case BucketActionKind.ServerShow: default: var view = await CosmosResourceFacade.GetThroughputBucketsAsync(state, databaseName, containerName, token); - return BuildShowResult(state, view); + return BuildShowResult(shell, state, view); } } catch (ThroughputBucketsNotSupportedException ex) @@ -331,16 +331,16 @@ private async Task ExecuteSetAsync(ConnectedState state, ShellInte return new CommandState { Result = new ShellJson(cancelledDoc.RootElement.Clone()), - RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-cancelled")), + RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-cancelled")), }; } var view = await CosmosResourceFacade.SetThroughputBucketAsync(state, databaseName, containerName, bucketId, percent, token); - var setResult = BuildShowResult(state, view); + var setResult = BuildShowResult(shell, state, view); var renderSet = setResult.RenderUser; setResult.RenderUser = () => { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-set_done")); + shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-set_done")); renderSet?.Invoke(); }; return setResult; @@ -356,16 +356,16 @@ private async Task ExecuteClearAsync(ConnectedState state, ShellIn return new CommandState { Result = new ShellJson(cancelledDoc.RootElement.Clone()), - RenderUser = () => ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-cancelled")), + RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-cancelled")), }; } var view = await CosmosResourceFacade.ClearThroughputBucketAsync(state, databaseName, containerName, bucketId, token); - var clearResult = BuildShowResult(state, view); + var clearResult = BuildShowResult(shell, state, view); var renderClear = clearResult.RenderUser; clearResult.RenderUser = () => { - ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-clear_done")); + shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-clear_done")); renderClear?.Invoke(); }; return clearResult; From 1df44787e5085be6844735c6c5d6f96f4ec0279e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Thu, 5 Nov 2026 14:48:53 +0100 Subject: [PATCH 15/15] Classify bucket and watch argument failures as usage errors in #254 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CosmosDBShell.Tests/CommandTests/CommandUsageErrorTests.cs | 4 ++++ CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs | 2 ++ CosmosDBShell.Tests/Integration/ShellProcessTests.cs | 4 ++++ .../Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs | 2 +- .../Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs | 2 +- .../Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs | 2 +- 6 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CosmosDBShell.Tests/CommandTests/CommandUsageErrorTests.cs b/CosmosDBShell.Tests/CommandTests/CommandUsageErrorTests.cs index 83c5ed27..1b99be1a 100644 --- a/CosmosDBShell.Tests/CommandTests/CommandUsageErrorTests.cs +++ b/CosmosDBShell.Tests/CommandTests/CommandUsageErrorTests.cs @@ -130,6 +130,10 @@ public async Task EveryBuiltInCommand_InvalidBindingIsUsageError(Type type, stri [InlineData("rm")] [InlineData("rm '*' --etag value")] [InlineData("watch --interval=NaN")] + [InlineData("bucket 99")] + [InlineData("bucket -1")] + [InlineData("query 'SELECT * FROM c' --bucket=99")] + [InlineData("query 'SELECT * FROM c' --bucket=-1")] public async Task CommandValidation_ReturnsUsageError(string text) { using var shell = ShellInterpreter.CreateInstance(); diff --git a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs index 95fe9ff3..68c631a8 100644 --- a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs @@ -211,6 +211,7 @@ public async Task ExecuteAsync_UnboundedMachineWatch_RejectsBeforeConnection(boo () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); Assert.Contains("positive --max", error.Message); + Assert.Equal(ShellExitCode.UsageError, ShellExitCode.FromException(error)); } [Theory] @@ -228,6 +229,7 @@ public async Task ExecuteAsync_UnboundedExecuteAndQuitWatch_RejectsIncludingRedi () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken)); Assert.Contains("positive --max", error.Message); + Assert.Equal(ShellExitCode.UsageError, ShellExitCode.FromException(error)); } [Theory] diff --git a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs index 878f9fbc..1b4d28c6 100644 --- a/CosmosDBShell.Tests/Integration/ShellProcessTests.cs +++ b/CosmosDBShell.Tests/Integration/ShellProcessTests.cs @@ -186,6 +186,10 @@ public async Task ReturnedCommandError_ThroughScriptOrExpression_IsReportedOnce( [InlineData("ftab --take=invalid")] [InlineData("ftab --take=999999999999999999999999999")] [InlineData("echo --unknown-usage-test-option")] + [InlineData("query 'SELECT * FROM c' --bucket=99")] + [InlineData("watch")] + [InlineData("watch --max=0")] + [InlineData("watch --max=-1")] public async Task InvalidCommandArguments_ReturnUsageExitCode(string command) { var result = await RunShellAsync( diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs index c3171377..dfb42e1b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BucketCommand.cs @@ -246,7 +246,7 @@ private CommandState RunClientCommand(ShellInterpreter shell, CosmosClient clien int bucket = int.Parse(this.Action!.Trim(), CultureInfo.InvariantCulture); if (!CheckBucket(bucket)) { - throw new CommandException( + throw CommandException.Usage( "bucket", MessageService.GetString("error-invalid_bucket_value", new Dictionary { { "bucket", bucket } })); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs index 6c396107..e3c32647 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs @@ -70,7 +70,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co { if (this.Bucket.HasValue && !BucketCommand.CheckBucket(this.Bucket.Value)) { - throw new CommandException( + throw CommandException.Usage( "query", MessageService.GetString("error-invalid_bucket_value", new Dictionary { { "bucket", this.Bucket.Value } })); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs index e83c3d54..f7bf3912 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs @@ -52,7 +52,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co if (shell.IsMachineMode && this.Max is not > 0) { - throw new CommandException("watch", MessageService.GetString("command-watch-error-machine_max_required")); + throw CommandException.Usage("watch", MessageService.GetString("command-watch-error-machine_max_required")); } if (shell.State is not ConnectedState connectedState)