diff --git a/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs b/CosmosDBShell.Analyzer/DirectConsoleOutputAnalyzer.cs new file mode 100644 index 00000000..c7729c93 --- /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 / Console.Clear. + if (method.IsStatic && type == "System.Console" && (method.Name.StartsWith("Write") || method.Name == "Clear")) + { + 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/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/BucketCommandTests.cs b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs index 97f068ed..aa1d7c02 100644 --- a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs @@ -4,18 +4,81 @@ 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; +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)] @@ -68,6 +131,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 +147,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,19 +162,20 @@ 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] - 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/CommandStatusOutputTests.cs b/CosmosDBShell.Tests/CommandTests/CommandStatusOutputTests.cs index eb77c162..ece28c07 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.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/MachineModeWriteErrorTests.cs b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs new file mode 100644 index 00000000..137f5672 --- /dev/null +++ b/CosmosDBShell.Tests/CommandTests/MachineModeWriteErrorTests.cs @@ -0,0 +1,321 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +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; +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); + } + } + } + + [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\"}", BulkWriteDiagnostics.MaximumCount + 3); + await File.WriteAllLinesAsync(path, records, TestContext.Current.CancellationToken); + var error = await CaptureMachineErrorAsync(shell, $"import '{path}' --continue-on-error --concurrency=16"); + + Assert.Equal( + BulkWriteDiagnostics.MaximumCount, + error.Split("Line ", StringSplitOptions.None).Length - 1); + Assert.Contains("3 additional item diagnostics omitted", error); + } + finally + { + File.Delete(path); + } + } + } + + [Fact] + public void BulkWriteDiagnostics_TruncatesLongMessages() + { + var diagnostics = new BulkWriteDiagnostics(); + diagnostics.Add(new string('x', BulkWriteDiagnostics.MaximumLength + 100)); + + var message = diagnostics.GetMessage(); + + Assert.True(message.Length <= BulkWriteDiagnostics.MaximumLength); + Assert.Contains("[diagnostic truncated]", message); + } + + [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 '[{\"pk\":\"x\"},{\"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); + } + } + + [Theory] + [InlineData("mkitem")] + [InlineData("replace")] + public async Task BulkWrite_TooManyFailures_BoundsDiagnosticsAndReportsOmittedCount(string commandName) + { + var (shell, container) = CreateShell(quiet: true); + using (shell) + { + ConfigureWrites(container); + var items = string.Join(',', Enumerable.Repeat("{\"id\":\"denied\",\"pk\":\"x\"}", BulkWriteDiagnostics.MaximumCount + 3)); + var error = await CaptureMachineErrorAsync(shell, $"{commandName} '[{items}]'"); + + Assert.Equal( + BulkWriteDiagnostics.MaximumCount, + error.Split("Item ", StringSplitOptions.None).Length - 1); + Assert.Contains("3 additional item diagnostics omitted", 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))); + + static Task WriteStream(Stream body) + { + using var document = JsonDocument.Parse(body); + if (document.RootElement.GetProperty("id").GetString() == "bad-status") + { + return Task.FromException( + new CosmosException("bad status", HttpStatusCode.BadRequest, 0, "test", 0)); + } + + return Task.FromException( + new CosmosException("bulk service denied", HttpStatusCode.Forbidden, 0, "test", 0)); + } + + container.CreateItemStreamAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => WriteStream(call.ArgAt(0))); + container.UpsertItemStreamAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => WriteStream(call.ArgAt(0))); + container.ReplaceItemStreamAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => WriteStream(call.ArgAt(0))); + } + + private static async Task CaptureMachineErrorAsync(ShellInterpreter shell, string command) + { + 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 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); + } + } +} diff --git a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs index 5cdc6e45..0be198bd 100644 --- a/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/QueryCommandTests.cs @@ -729,6 +729,65 @@ public async Task ExecuteQueryAsync_ResumableQueryAtLimit_ReportsLimitWithoutRes Assert.DoesNotContain("cannot be resumed", output.Text); } + [Theory] + [InlineData(false, null)] + [InlineData(true, null)] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task ExecuteQueryAsync_LimitWarnings_UseExecutingShellOutput(bool quiet, bool? append) + { + using var shell = ShellInterpreter.CreateInstance(); + shell.Options = new Program.CosmosShellOptions { Output = "json", Quiet = quiet }; + var singletonOptions = ShellInterpreter.Instance.Options; + var originalOut = Console.Out; + var originalError = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + var errorFile = Path.Join(Path.GetTempPath(), $"cosmosshell-query-warnings-{Guid.NewGuid():N}.log"); + try + { + ShellInterpreter.Instance.Options = new Program.CosmosShellOptions { Output = "user" }; + Console.SetOut(stdout); + Console.SetError(stderr); + if (append.HasValue) + { + await File.WriteAllTextAsync(errorFile, "existing\n", TestContext.Current.CancellationToken); + shell.ErrOutRedirect = errorFile; + shell.AppendErrRedirection = append.Value; + } + + using var iterator = new FakeFeedIterator( + NonResumablePage("Continuation tokens are not supported.", 1, "1", "2", "3")); + var command = new QueryCommand { Query = "SELECT * FROM c", Max = 2 }; + var output = await CaptureConsoleAsync( + () => command.ExecuteQueryAsync(CreateContainer(iterator), shell, TestContext.Current.CancellationToken)); + + Assert.Equal(["1", "2"], ReadIds(output.Result)); + Assert.Empty(output.Text); + Assert.Empty(stdout.ToString()); + var diagnostics = append.HasValue + ? await File.ReadAllTextAsync(errorFile, TestContext.Current.CancellationToken) + : stderr.ToString(); + Assert.Contains("Results limited to 2 items", diagnostics); + Assert.Contains("cannot be resumed", diagnostics); + Assert.DoesNotContain("\u001b", diagnostics, StringComparison.Ordinal); + if (append.HasValue) + { + Assert.Empty(stderr.ToString()); + Assert.Equal(append.Value, diagnostics.StartsWith("existing", StringComparison.Ordinal)); + } + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + ShellInterpreter.Instance.Options = singletonOptions; + File.Delete(errorFile); + } + } + private static Container CreateContainer(FeedIterator iterator) { var container = Substitute.For(); 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..581c0ad2 100644 --- a/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/ThemeCommandTests.cs @@ -81,10 +81,44 @@ 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] + public async Task Validate_DirectoryUsesExecutingShellMachineMode() + { + using var dir = new TempDirectory(); + var broken = Path.Join(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] @@ -109,9 +143,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 2dc9671e..68c631a8 100644 --- a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs +++ b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs @@ -4,12 +4,13 @@ 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.Parser; using Azure.Data.Cosmos.Shell.States; -using System.Net; -using System.Text; using Microsoft.Azure.Cosmos; using NSubstitute; @@ -22,6 +23,73 @@ namespace CosmosShell.Tests.CommandTests; [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() { @@ -125,6 +193,63 @@ 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); + Assert.Equal(ShellExitCode.UsageError, ShellExitCode.FromException(error)); + } + + [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); + Assert.Equal(ShellExitCode.UsageError, ShellExitCode.FromException(error)); + } + + [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)); + } + [Theory] [InlineData(false, false, false)] [InlineData(true, false, false)] diff --git a/CosmosDBShell.Tests/CosmosDBShell.Tests.csproj b/CosmosDBShell.Tests/CosmosDBShell.Tests.csproj index 036b04d4..d5b19433 100644 --- a/CosmosDBShell.Tests/CosmosDBShell.Tests.csproj +++ b/CosmosDBShell.Tests/CosmosDBShell.Tests.csproj @@ -24,6 +24,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -34,6 +35,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/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 a84e6fc3..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( @@ -222,6 +226,114 @@ public async Task ImportDryRun_InvalidConcurrency_ReturnsUsageExitCode() } } + [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", ShellExitCode.GeneralFailure)] + [InlineData("help MACHINE_MISSING", "MACHINE_MISSING", ShellExitCode.UsageError)] + [InlineData("edit machine.txt", "interactive terminal", ShellExitCode.GeneralFailure)] + public async Task MachineMode_CommandFailure_ReportsSingleStructuredError(string command, string expected, int expectedExitCode) + { + var result = await RunShellAsync( + stdinScript: null, + extraArgs: ["-c", command], + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(expectedExitCode, 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, "; ")] [InlineData(true, "; ")] @@ -578,6 +690,171 @@ public async Task ImportDryRun_InvalidId_ReturnsUsageExitCodeAndOfflineResult(st } } + [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()); + 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 + { + File.Delete(Path.Join(path, "a-warning.toml")); + File.Delete(Path.Join(path, "z-broken.toml")); + Directory.Delete(path); + } + } + + [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")] + 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")] @@ -1174,6 +1451,7 @@ private static async Task RunShellAsync( process.ExitCode, Strip(stdOut.ToString()), Strip(rawStdErr), + stdOut.ToString(), rawStdErr); } finally @@ -1210,5 +1488,5 @@ private static string Strip(string text) return AnsiEscape.Replace(text, string.Empty); } - private sealed record ShellProcessResult(int ExitCode, string StdOut, string StdErr, string RawStdErr); + private sealed record ShellProcessResult(int ExitCode, string StdOut, string StdErr, string RawStdOut, string RawStdErr); } diff --git a/CosmosDBShell.Tests/Parser/CommandStatementTests.cs b/CosmosDBShell.Tests/Parser/CommandStatementTests.cs index 68e31a9d..ef21a867 100644 --- a/CosmosDBShell.Tests/Parser/CommandStatementTests.cs +++ b/CosmosDBShell.Tests/Parser/CommandStatementTests.cs @@ -127,6 +127,38 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS } } + [CosmosCommand("output-warning-test")] + internal sealed class OutputWarningTestCommand : CosmosCommand + { + [CosmosOption("fail")] + public bool? Fail { get; init; } + + public override Task ExecuteAsync(ShellInterpreter shell, CommandState commandState, string commandText, CancellationToken token) + { + shell.Output.WriteLine(ShellMessageKind.Warning, "first warning"); + shell.Output.MarkupLine(ShellMessageKind.RequiredInstruction, "second [bold]instruction[/]"); + if (this.Fail == true) + { + throw new CommandException("output-warning-test", "output failure"); + } + + commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { value = "ok" })); + return Task.FromResult(commandState); + } + } + + [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 { @@ -622,6 +654,107 @@ await File.WriteAllTextAsync( } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task MachineMode_ShellOutputStandardErrorHonorsErrorRedirect(bool append) + { + using var shell = ShellInterpreter.CreateInstance(); + Assert.True(CommandFactory.TryCreateFactory(typeof(OutputWarningTestCommand), out var factory)); + shell.App.Commands["output-warning-test"] = factory; + shell.Options = new Program.CosmosShellOptions { Output = "json" }; + var script = Path.GetTempFileName(); + var stderr = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(stderr, "existing" + Environment.NewLine, TestContext.Current.CancellationToken); + await File.WriteAllTextAsync( + script, + $"output-warning-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 instruction{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); + } + } + + [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() + { + using var shell = ShellInterpreter.CreateInstance(); + Assert.True(CommandFactory.TryCreateFactory(typeof(OutputWarningTestCommand), out var factory)); + shell.App.Commands["output-warning-test"] = factory; + shell.Options = new Program.CosmosShellOptions { Output = "json" }; + var stderr = Path.GetTempFileName(); + shell.ErrOutRedirect = stderr; + try + { + var result = await shell.ExecuteCommandAsync("output-warning-test --fail", TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + var lines = (await File.ReadAllTextAsync(stderr, TestContext.Current.CancellationToken)) + .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(3, lines.Length); + Assert.Equal("first warning", lines[0]); + Assert.Equal("second instruction", lines[1]); + using var document = JsonDocument.Parse(lines[2]); + Assert.Equal("error", document.RootElement.GetProperty("status").GetString()); + Assert.Contains("output failure", document.RootElement.GetProperty("error").GetString()); + } + finally + { + shell.ErrOutRedirect = null; + File.Delete(stderr); + } + } + [Fact] public async Task Script_PreservesWarningTrackingForInheritedRedirect() { 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/Shell/NonAnsiFallbackTests.cs b/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs index c5a7d041..ec751f1a 100644 --- a/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs +++ b/CosmosDBShell.Tests/Shell/NonAnsiFallbackTests.cs @@ -10,6 +10,55 @@ namespace CosmosShell.Tests.Shell; [Collection(ThemeStateTestCollection.Name)] public class NonAnsiFallbackTests { + [Theory] + [InlineData("echo QUIET_HISTORY", "echo QUIET_HISTORY")] + [InlineData("connect 'AccountKey=example-key==;'", "connect 'AccountKey=[REDACTED];'")] + public void PrintCommand_Quiet_RecordsInExistingEditorHistoryWithoutEcho(string command, string expectedHistory) + { + 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(command); + + Assert.Equal(historyCount + 1, editor.History.Count); + Assert.Contains(expectedHistory, shell.History); + Assert.Contains(expectedHistory, 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.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 51c52f77..538bc508 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BatchCommand.cs @@ -95,7 +95,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) @@ -106,7 +106,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 { @@ -155,7 +155,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) { @@ -175,7 +175,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) @@ -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) { @@ -269,7 +269,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) @@ -297,7 +297,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) @@ -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 0ace3efb..2713c656 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.WriteLine(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.WriteLine(message); + state.RenderUser = () => shell.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 7388e7ef..dfb42e1b 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); @@ -108,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) @@ -120,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 @@ -138,11 +130,11 @@ private static bool ConfirmWrite(ShellInterpreter shell, bool yes, string summar return true; } - AnsiConsole.MarkupLine(MessageService.GetArgsString(summaryKey, args)); + shell.Output.MarkupLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString(summaryKey, args)); 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) @@ -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))); + shell.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetArgsString("command-bucket-no_limits", "resource", Markup.Escape(view.ResourceName))); } else { @@ -191,23 +183,23 @@ private static CommandState BuildShowResult(ConnectedState state, ThroughputBuck Theme.FormatTableValue(bucket.MaxThroughputPercentage.ToString(CultureInfo.InvariantCulture) + "%")); } - AnsiConsole.Write(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) { - AnsiConsole.MarkupLine(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 { - AnsiConsole.MarkupLine(MessageService.GetString("command-bucket-no_bucket")); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-bucket-no_bucket")); } } @@ -242,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,26 +246,35 @@ private CommandState RunClientCommand(CosmosClient client) int bucket = int.Parse(this.Action!.Trim(), CultureInfo.InvariantCulture); if (!CheckBucket(bucket)) { - return new CommandState(); + throw CommandException.Usage( + "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")); + shell.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)) } })); + 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(); + 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) @@ -303,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) @@ -330,16 +331,16 @@ 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 = () => 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.WriteLine(MessageService.GetString("command-bucket-set_done")); + shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-set_done")); renderSet?.Invoke(); }; return setResult; @@ -355,16 +356,16 @@ 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 = () => 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.WriteLine(MessageService.GetString("command-bucket-clear_done")); + shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-bucket-clear_done")); renderClear?.Invoke(); }; return clearResult; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BulkCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BulkCommand.cs index c24f0051..4de090c8 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BulkCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/BulkCommand.cs @@ -566,14 +566,14 @@ private static CommandState Status(ShellInterpreter shell) ["uncertain"] = uncertain, ["operations"] = operations, }; - renderUser = () => RenderStatus(bulk, succeeded, failed, uncertain); + renderUser = () => RenderStatus(shell, bulk, succeeded, failed, uncertain); } using var document = JsonDocument.Parse(root.ToJsonString()); return new CommandState { Result = new ShellJson(document.RootElement.Clone()), RenderUser = renderUser }; } - private static void RenderStatus(PendingBulkState bulk, int succeeded, int failed, int uncertain) + private static void RenderStatus(ShellInterpreter shell, PendingBulkState bulk, int succeeded, int failed, int uncertain) { var details = new Table().HideHeaders(); details.AddColumn(string.Empty); @@ -593,7 +593,7 @@ void AddDetail(string labelKey, string value) => AddDetail("command-bulk-status-succeeded", succeeded.ToString(CultureInfo.InvariantCulture)); AddDetail("command-bulk-status-failed", failed.ToString(CultureInfo.InvariantCulture)); AddDetail("command-bulk-status-uncertain", uncertain.ToString(CultureInfo.InvariantCulture)); - AnsiConsole.Write(details); + shell.Output.Render(ShellMessageKind.Result, details); if (bulk.Operations.Count == 0) { return; @@ -611,10 +611,10 @@ void AddDetail(string labelKey, string value) => Theme.FormatTableValue(Markup.Escape(operation.Id))); } - AnsiConsole.Write(operations); + shell.Output.Render(ShellMessageKind.Result, operations); if (bulk.Operations.Count > StatusPreviewCount) { - ShellInterpreter.WriteLine(MessageService.GetArgsString("command-bulk-status-more", "count", bulk.Operations.Count - StatusPreviewCount)); + shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetArgsString("command-bulk-status-more", "count", bulk.Operations.Count - StatusPreviewCount)); } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/CanICommand.cs index ee69b069..d4a8b761 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 6cba9f8e..6a9c7018 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 30463d96..e2bdb32c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConflictCommand.cs @@ -63,7 +63,7 @@ async Task IStateVisitor.VisitConn string? container = NormalizeOption(this.Container); if (database is not null && container is not null) { - return await this.ExecuteOnContainerAsync(state, database, container, token); + return await this.ExecuteOnContainerAsync(state, shell, database, container, token); } throw new NotInContainerException("conflict"); @@ -76,7 +76,7 @@ async Task IStateVisitor.VisitData if (container is not null) { - return await this.ExecuteOnContainerAsync(state, databaseName, container, token); + return await this.ExecuteOnContainerAsync(state, shell, databaseName, container, token); } throw new NotInContainerException("conflict"); @@ -87,7 +87,7 @@ async Task IStateVisitor.VisitCont string databaseName = NormalizeOption(this.Database) ?? state.DatabaseName; string containerName = NormalizeOption(this.Container) ?? state.ContainerName; - return await this.ExecuteOnContainerAsync(state, databaseName, containerName, token); + return await this.ExecuteOnContainerAsync(state, shell, databaseName, containerName, token); } /// @@ -145,7 +145,7 @@ private static CommandState BuildResult(string containerName, ConflictResolution }; } - private async Task ExecuteOnContainerAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + private async Task ExecuteOnContainerAsync(ConnectedState state, ShellInterpreter shell, string databaseName, string containerName, CancellationToken token) { // Validate the subcommand and options that do not depend on the current policy // before touching the network so bad input fails fast without a round-trip. @@ -173,7 +173,7 @@ private async Task ExecuteOnContainerAsync(ConnectedState state, s return BuildResult(containerName, current); } - return await this.SetAsync(state, databaseName, containerName, normalizedMode, token); + return await this.SetAsync(state, shell, databaseName, containerName, normalizedMode, token); } /// @@ -208,7 +208,7 @@ private async Task ExecuteOnContainerAsync(ConnectedState state, s return normalizedMode; } - private async Task SetAsync(ConnectedState state, string databaseName, string containerName, string? normalizedMode, CancellationToken token) + private async Task SetAsync(ConnectedState state, ShellInterpreter shell, string databaseName, string containerName, string? normalizedMode, CancellationToken token) { var current = await CosmosResourceFacade.GetConflictResolutionPolicyAsync(state, databaseName, containerName, token); string effectiveMode = normalizedMode ?? (IsCustom(current.Mode) ? "custom" : "lastWriterWins"); @@ -245,7 +245,7 @@ private async Task SetAsync(ConnectedState state, string databaseN var view = await CosmosResourceFacade.ReplaceConflictResolutionPolicyAsync(state, databaseName, containerName, update, token); var result = BuildResult(containerName, view); - result.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-conflict-updated")); + result.RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-conflict-updated")); return result; } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs index 85f859da..4e350801 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ConnectCommand.cs @@ -76,7 +76,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)); } } @@ -153,8 +153,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)); } /// @@ -165,7 +165,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)) { @@ -185,17 +185,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) @@ -210,7 +210,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; @@ -259,7 +259,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); @@ -283,7 +283,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 73486157..d7b05fa3 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 943c9683..cdb90ca1 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 14a54a85..6ac4e412 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 (shell.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, @@ -98,8 +95,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) @@ -110,12 +106,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 ca67914c..80abbc7c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ExportCommand.cs @@ -102,7 +102,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co requestCharge = charge, })); resultState.RequestCharge = charge; - resultState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetArgsString( + resultState.RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, 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 ef01d0e6..5b15c754 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 40a7abe5..a1b4f66e 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/HelpCommand.cs @@ -90,9 +90,7 @@ public static CommandState PrintCommandHelp(string cmdStr, CommandRunner app, bo } // Neither command, statement, nor help topic - AnsiConsole.Markup(Theme.FormatError(MessageService.GetString("error")) + " "); - ShellInterpreter.WriteLine(MessageService.GetString("error-command-not-found", MessageService.Args("command", cmdStr))); - return new ErrorCommandState(CommandException.Usage("help", cmdStr + " not found.")); + throw CommandException.Usage("help", MessageService.GetString("error-command-not-found", MessageService.Args("command", cmdStr))); } if (cmd == null) @@ -107,69 +105,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}"); } } } @@ -179,31 +177,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, "]] "); } } @@ -219,16 +217,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) { @@ -266,8 +264,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); } } @@ -303,24 +301,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); @@ -353,11 +351,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); } } } @@ -586,8 +584,8 @@ private static void PrintKeyboardShortcuts(bool plain) BorderStyle = Theme.GetHelpBorderStyle(), Padding = new Padding(1, 0, 1, 0), }; - AnsiConsole.Write(headerPanel); - AnsiConsole.MarkupLine(INDENT + Theme.FormatHelpDescription(MessageService.GetString("help-keyboard-context"))); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, headerPanel); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, INDENT + Theme.FormatHelpDescription(MessageService.GetString("help-keyboard-context"))); ShellInterpreter.WriteLine(); foreach (var group in groups) @@ -604,11 +602,11 @@ private static void PrintKeyboardShortcuts(bool plain) table.AddRow(INDENT + FormatShortcutKeys(shortcut.Shortcut), Theme.FormatHelpDescription(shortcut.Description)); } - AnsiConsole.Write(table); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); ShellInterpreter.WriteLine(); } - AnsiConsole.MarkupLine(INDENT + Theme.FormatHelpAccent("\u25b6") + " " + Theme.FormatHelpDescription(MessageService.GetString("help-keyboard-search-note"))); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, INDENT + Theme.FormatHelpAccent("\u25b6") + " " + Theme.FormatHelpDescription(MessageService.GetString("help-keyboard-search-note"))); ShellInterpreter.WriteLine(); } @@ -632,7 +630,7 @@ private static void PrintHelpTopics(bool plain) BorderStyle = Theme.GetHelpBorderStyle(), Padding = new Padding(1, 0, 1, 0), }; - AnsiConsole.Write(panel); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, panel); ShellInterpreter.WriteLine(); var table = new Table() @@ -642,8 +640,8 @@ private static void PrintHelpTopics(bool plain) .AddColumn(new TableColumn("Description")); table.AddRow(INDENT + Theme.FormatHelpAccent(ShortcutsTopic), Theme.FormatHelpDescription(description)); - AnsiConsole.Write(table); - AnsiConsole.MarkupLine(INDENT + Theme.FormatMuted(MessageService.GetString("help-topics-hint"))); + ShellInterpreter.Instance.Output.Render(ShellMessageKind.Result, table); + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Result, INDENT + Theme.FormatMuted(MessageService.GetString("help-topics-hint"))); ShellInterpreter.WriteLine(); } @@ -654,8 +652,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 @@ -869,8 +867,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) @@ -887,8 +885,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); } } @@ -941,7 +939,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) @@ -985,21 +983,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 { @@ -1010,8 +1008,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 c8cb73ea..1f7b6162 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ImportCommand.cs @@ -732,6 +732,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co var partitionKeySegments = ParsePartitionKeySegments(this.PartitionKey); var progress = new ImportProgress(); + var errors = shell.IsMachineMode ? new BulkWriteDiagnostics() : null; var reporter = new IngestionProgress( shell, selectedFormat, @@ -758,7 +759,8 @@ await reporter.RunAsync(() => ExecuteImportAsync( commandState, progress, token, - UpdateProgress)); + UpdateProgress, + errors)); } catch (DecoderFallbackException ex) { @@ -841,12 +843,20 @@ await reporter.RunAsync(() => ExecuteImportAsync( } CommandState resultState; + var failureMessage = failure?.Message; + if (errors is { Count: > 0 }) + { + failureMessage = ReferenceEquals(failure, progress.FirstFailure) + ? errors.GetMessage() + : failureMessage + Environment.NewLine + errors.GetMessage(); + } + if (failure != null) { - payload["error"] = failure.Message; + payload["error"] = failureMessage; payload["exitCode"] = ShellExitCode.FromException(failure); resultState = new StructuredErrorCommandState( - new CommandException("import", $"{summary} {failure.Message}", failure), + new CommandException("import", $"{summary} {failureMessage}", failure), new ShellJson(JsonSerializer.SerializeToElement(payload))); } else @@ -864,10 +874,10 @@ await reporter.RunAsync(() => ExecuteImportAsync( resultState.RequestCharge = charge > 0 ? charge : null; resultState.RenderUser = () => { - ShellInterpreter.WriteLine(summary); + shell.Output.WriteLine(ShellMessageKind.Result, summary); if (failure != null && shell.ErrOutRedirect == null) { - ShellInterpreter.WriteLine(failure.Message); + shell.Output.WriteLine(ShellMessageKind.Error, failureMessage!); } }; return resultState; @@ -887,7 +897,8 @@ private static async Task ExecuteImportAsync( CommandState commandState, ImportProgress progress, CancellationToken token, - Action progressChanged) + Action progressChanged, + BulkWriteDiagnostics? errors) { FileStream? stream = null; IAsyncEnumerable<(int LineNumber, JsonElement Item)> source; @@ -928,7 +939,8 @@ await WriteItemsAsync( token, warningShell: shell, progress: progress, - progressChanged: progressChanged); + progressChanged: progressChanged, + errors: errors); } finally { @@ -990,7 +1002,8 @@ private static void ValidateDryRunItem(JsonElement item, int lineNumber) CancellationToken token, ShellInterpreter? warningShell = null, ImportProgress? progress = null, - Action? progressChanged = null) + Action? progressChanged = null, + BulkWriteDiagnostics? errors = null) { ArgumentOutOfRangeException.ThrowIfLessThan(concurrency, 1); progress ??= new ImportProgress(); @@ -1009,6 +1022,7 @@ void Record((double Charge, Exception? Error) result) { progress.Failed++; progress.FirstFailure ??= result.Error; + errors?.Add(result.Error.Message); } progressChanged?.Invoke(); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs index e97c366d..582dac51 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/IndexCommand.cs @@ -63,7 +63,7 @@ async Task IStateVisitor.VisitConn { if (!string.IsNullOrEmpty(this.Database) && !string.IsNullOrEmpty(this.Container)) { - return await this.ExecuteOnContainerAsync(state, this.Database, this.Container, token); + return await this.ExecuteOnContainerAsync(state, shell, this.Database, this.Container, token); } throw new NotInContainerException("index"); @@ -75,7 +75,7 @@ async Task IStateVisitor.VisitData if (!string.IsNullOrEmpty(this.Container)) { - return await this.ExecuteOnContainerAsync(state, databaseName, this.Container, token); + return await this.ExecuteOnContainerAsync(state, shell, databaseName, this.Container, token); } throw new NotInContainerException("index"); @@ -86,7 +86,7 @@ async Task IStateVisitor.VisitCont string databaseName = this.Database ?? state.DatabaseName; string containerName = this.Container ?? state.ContainerName; - return await this.ExecuteOnContainerAsync(state, databaseName, containerName, token); + return await this.ExecuteOnContainerAsync(state, shell, databaseName, containerName, token); } /// @@ -309,7 +309,7 @@ private static CommandState BuildResult(string json) }; } - private async Task ExecuteOnContainerAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + private async Task ExecuteOnContainerAsync(ConnectedState state, ShellInterpreter shell, string databaseName, string containerName, CancellationToken token) { await ValidateContainerExistsAsync(state, databaseName, containerName, "index", token); @@ -318,11 +318,11 @@ private async Task ExecuteOnContainerAsync(ConnectedState state, s case "show": return await this.ShowAsync(state, databaseName, containerName, token); case "add": - return await this.AddAsync(state, databaseName, containerName, token); + return await this.AddAsync(state, shell, databaseName, containerName, token); case "remove": - return await this.RemoveAsync(state, databaseName, containerName, token); + return await this.RemoveAsync(state, shell, databaseName, containerName, token); case "set": - return await this.SetAsync(state, databaseName, containerName, token); + return await this.SetAsync(state, shell, databaseName, containerName, token); default: throw CommandException.Usage( "index", @@ -342,23 +342,23 @@ private async Task ShowAsync(ConnectedState state, string database return BuildResult(json); } - private async Task AddAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + private async Task AddAsync(ConnectedState state, ShellInterpreter shell, string databaseName, string containerName, CancellationToken token) { var paths = this.RequirePaths(); string current = await this.ReadPolicyAsync(state, databaseName, containerName, token); string updated = AddIncludedPaths(current, paths); - return await this.WritePolicyAsync(state, databaseName, containerName, updated, token); + return await this.WritePolicyAsync(state, shell, databaseName, containerName, updated, token); } - private async Task RemoveAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + private async Task RemoveAsync(ConnectedState state, ShellInterpreter shell, string databaseName, string containerName, CancellationToken token) { var paths = this.RequirePaths(); string current = await this.ReadPolicyAsync(state, databaseName, containerName, token); string updated = RemovePaths(current, paths); - return await this.WritePolicyAsync(state, databaseName, containerName, updated, token); + return await this.WritePolicyAsync(state, shell, databaseName, containerName, updated, token); } - private async Task SetAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + private async Task SetAsync(ConnectedState state, ShellInterpreter shell, string databaseName, string containerName, CancellationToken token) { bool? automatic = ParseAutomatic(this.Automatic); string? policyArgument = this.PolicyArgument(); @@ -370,7 +370,7 @@ private async Task SetAsync(ConnectedState state, string databaseN string baseJson = policyArgument ?? await this.ReadPolicyAsync(state, databaseName, containerName, token); string updated = ApplySettings(baseJson, this.Mode, automatic); - return await this.WritePolicyAsync(state, databaseName, containerName, updated, token); + return await this.WritePolicyAsync(state, shell, databaseName, containerName, updated, token); } private string[] RequirePaths() @@ -406,7 +406,7 @@ private async Task ReadPolicyAsync(ConnectedState state, string database } } - private async Task WritePolicyAsync(ConnectedState state, string databaseName, string containerName, string policyJson, CancellationToken token) + private async Task WritePolicyAsync(ConnectedState state, ShellInterpreter shell, string databaseName, string containerName, string policyJson, CancellationToken token) { string updatedJson; try @@ -419,7 +419,7 @@ private async Task WritePolicyAsync(ConnectedState state, string d } var result = BuildResult(updatedJson); - result.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-index-updated")); + result.RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-index-updated")); return result; } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/InfoCommand.cs index 8a55b8fa..7493feab 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.Error, indent + Theme.FormatError(MessageService.GetString("error")) + " "); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, line); } else if (line.Length == 0) { - ShellInterpreter.WriteLine(); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error); } else { - AnsiConsole.Markup(indent); - ShellInterpreter.WriteLine(line); + ShellInterpreter.Instance.Output.Markup(ShellMessageKind.Error, indent); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Error, 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 ae505f93..1eb7b9bc 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 }, }))); @@ -295,8 +295,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()) }, @@ -304,7 +304,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 4f6976fe..75099806 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeContainerCommand.cs @@ -67,7 +67,7 @@ async Task IStateVisitor.VisitConn { if (!string.IsNullOrEmpty(this.Database)) { - return await this.CreateContainerInDatabaseAsync(state, this.Database, token); + return await this.CreateContainerInDatabaseAsync(state, shell, this.Database, token); } throw new NotInDatabaseException("mkcon"); @@ -128,10 +128,10 @@ async Task IStateVisitor.VisitData { if (!string.IsNullOrEmpty(this.Database) && !string.Equals(this.Database, state.DatabaseName, StringComparison.OrdinalIgnoreCase)) { - return await this.CreateContainerInDatabaseAsync(state, this.Database, token); + return await this.CreateContainerInDatabaseAsync(state, shell, this.Database, token); } - return await this.CreateContainerInDatabaseAsync(state, state.DatabaseName, token); + return await this.CreateContainerInDatabaseAsync(state, shell, state.DatabaseName, token); } Task IStateVisitor.VisitContainerStateAsync(ContainerState state, ShellInterpreter shell, CancellationToken token) @@ -144,7 +144,7 @@ Task IStateVisitor.VisitContainerS throw new CommandException("mkcon", MessageService.GetString("error-not_allowed_in_container")); } - private async Task CreateContainerInDatabaseAsync(ConnectedState state, string databaseName, CancellationToken token) + private async Task CreateContainerInDatabaseAsync(ConnectedState state, ShellInterpreter shell, string databaseName, CancellationToken token) { // Validate database exists await ValidateDatabaseExistsAsync(state, databaseName, "mkcon", token); @@ -189,7 +189,7 @@ private async Task CreateContainerInDatabaseAsync(ConnectedState s ResourceCreationOutcome.CreatedOrUpdated => "command-mkcon-container_created_or_updated", _ => throw new UnreachableException(), }; - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString(messageKey, new Dictionary { { "container", creation.Id } })); + commandState.RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString(messageKey, new Dictionary { { "container", creation.Id } })); return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs index a03da155..39d8aed0 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeDbCommand.cs @@ -70,7 +70,7 @@ async Task IStateVisitor.VisitConn ResourceCreationOutcome.CreatedOrUpdated => "command-mkdb-database_created_or_updated", _ => throw new UnreachableException(), }; - commandState.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString(messageKey, new Dictionary { { "db", creation.Id } })); + commandState.RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString(messageKey, new Dictionary { { "db", creation.Id } })); return commandState; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs index 3245d520..dd6343f7 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/MakeItemCommand.cs @@ -74,7 +74,7 @@ public async override Task ExecuteAsync(ShellInterpreter shell, Co { if (summary.Message is { } message) { - ShellInterpreter.WriteLine(message); + shell.Output.WriteLine(ShellMessageKind.Result, message); } }; return returnState; @@ -166,8 +166,23 @@ private static async Task WriteItemAsync( int replacedCount = 0; int failCount = 0; double charge = 0.0; + var errors = shell.IsMachineMode ? new BulkWriteDiagnostics() : null; + var itemIndex = 0; + void RecordError(string message) + { + if (errors != null) + { + errors.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", message)); + } + else + { + shell.Output.WriteLine(ShellMessageKind.Error, message); + } + } + foreach (var element in root.EnumerateArray()) { + itemIndex++; try { var result = await WriteOneAsync(container, partitionKeyPaths, element, force, token); @@ -183,7 +198,7 @@ private static async Task WriteItemAsync( else { failCount++; - ShellInterpreter.WriteLine( + RecordError( MessageService.GetArgsString( "command-mkitem-error-status-returned", "status", @@ -194,7 +209,7 @@ private static async Task WriteItemAsync( { RequestChargeContext.Record(ce.RequestCharge); failCount++; - ShellInterpreter.WriteLine( + RecordError( MessageService.GetArgsString( "command-mkitem-error-creation-failed", "status", @@ -205,7 +220,7 @@ private static async Task WriteItemAsync( catch (Exception ex) when (ex is JsonException or FormatException or ArgumentException) { failCount++; - ShellInterpreter.WriteLine( + RecordError( MessageService.GetArgsString( "command-mkitem-error-creation-failed", "status", @@ -283,16 +298,24 @@ private static async Task WriteItemAsync( // treat a partial/total failure as success. if (failCount > 0) { - ShellInterpreter.WriteLine(summaryMessage); + if (!shell.IsMachineMode) + { + shell.Output.WriteLine(ShellMessageKind.Error, summaryMessage); + } + var totalCount = createdCount + replacedCount + failCount; - throw new CommandException( - "mkitem", - MessageService.GetArgsString( + var message = MessageService.GetArgsString( "command-mkitem-error-array_failed", "failed", failCount, "total", - totalCount)); + totalCount); + if (errors != null) + { + message += Environment.NewLine + errors.GetMessage(); + } + + throw new CommandException("mkitem", message); } return new WriteSummary(createdCount, replacedCount, failCount, charge, summaryMessage); @@ -326,11 +349,10 @@ private static async Task WriteItemAsync( } else { - ShellInterpreter.WriteLine(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, null); } } catch (CosmosException ce) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs index d16bda33..1c758393 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/PwdCommand.cs @@ -47,8 +47,8 @@ public override Task ExecuteAsync(ShellInterpreter shell, CommandS var cosmosLabel = MessageService.GetString("command-pwd-cosmos-location") + ":"; var localLabel = MessageService.GetString("command-pwd-local-directory") + ":"; var width = Math.Max(cosmosLabel.Length, localLabel.Length) + 1; - AnsiConsole.MarkupLine(Theme.FormatMuted(cosmosLabel.PadRight(width)) + currentLocationMarkup); - AnsiConsole.MarkupLine(Theme.FormatMuted(localLabel.PadRight(width)) + Theme.FormatDirectory(localDirectory)); + shell.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(cosmosLabel.PadRight(width)) + currentLocationMarkup); + shell.Output.MarkupLine(ShellMessageKind.Result, Theme.FormatMuted(localLabel.PadRight(width)) + Theme.FormatDirectory(localDirectory)); }; return Task.FromResult(commandState); } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/QueryCommand.cs index 36ab7f72..e3c32647 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 CommandException.Usage( + "query", + MessageService.GetString("error-invalid_bucket_value", new Dictionary { { "bucket", this.Bucket.Value } })); } // Get connected state @@ -560,13 +562,13 @@ private static ShellJson BuildExplainJson(string? query, PlanEvaluation evaluati return new ShellJson(element); } - private static void RenderExplain(PlanEvaluation evaluation, double requestCharge, IReadOnlyList messages) + private static void RenderExplain(ShellInterpreter shell, PlanEvaluation evaluation, double requestCharge, IReadOnlyList messages) { - AnsiConsole.MarkupLine(MessageService.GetString("command-query-explain-header")); + shell.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-query-explain-header")); foreach (var message in messages) { - AnsiConsole.MarkupLine(Markup.Escape(message)); + shell.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); + shell.Output.Render(ShellMessageKind.Result, table); - AnsiConsole.MarkupLine(MessageService.GetString("command-query-explain-estimate_note")); + shell.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-query-explain-estimate_note")); } private async Task ThrowIfRequestFailedAsync(ResponseMessage response, ShellInterpreter shell) @@ -680,7 +682,7 @@ private async Task ExecuteExplainAsync(Container container, ShellI var messages = BuildPlanMessages(evaluation); returnState.Result = BuildExplainJson(this.Query, evaluation, requestCharge, messages); - returnState.RenderUser = () => RenderExplain(evaluation, requestCharge, messages); + returnState.RenderUser = () => RenderExplain(shell, evaluation, requestCharge, messages); returnState.RequestCharge = requestCharge; return returnState; } @@ -768,14 +770,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() } })); + shell.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) } })); + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetString("command-query-request_charge", new Dictionary { { "charge", pageRequestCharge.ToString("F2", CultureInfo.InvariantCulture) } })); if (TryReadContinuationToken(response, out var pageContinuationToken)) { @@ -873,7 +875,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); + shell.Output.Render(ShellMessageKind.Result, table); table = new Table(); table.AddColumns(string.Empty, string.Empty, string.Empty, string.Empty); @@ -883,13 +885,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); + shell.Output.MarkupLine(ShellMessageKind.Result, MessageService.GetString("command-query-time_label")); + shell.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); + shell.Output.Render(ShellMessageKind.Result, table); if (!string.IsNullOrWhiteSpace(response.IndexMetrics)) { @@ -917,7 +919,7 @@ internal async Task ExecuteQueryAsync(Container container, ShellIn } } - AnsiConsole.Write(indexTable); + shell.Output.Render(ShellMessageKind.Result, indexTable); } } else @@ -943,10 +945,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 } })); + shell.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")); + shell.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 221840d3..143b388c 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ReplaceCommand.cs @@ -62,7 +62,7 @@ public override async Task ExecuteAsync(ShellInterpreter shell, Co requestCharge = summary.RequestCharge, })), RequestCharge = summary.RequestCharge, - RenderUser = () => ShellInterpreter.WriteLine(summary.Message), + RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, summary.Message), }; return resultState; } @@ -94,7 +94,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 && HasUndefinedPartitionKeyComponent(root, partitionKeyPaths) ? 1 : 0; @@ -109,15 +109,18 @@ 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; bool hasUsageError = false; double charge = 0.0; + var errors = shell.IsMachineMode ? new BulkWriteDiagnostics() : null; + var itemIndex = 0; foreach (var element in arrayRoot.EnumerateArray()) { + itemIndex++; try { var itemCharge = await ReplaceOneAsync(container, partitionKeyPaths, element, etag: null, token); @@ -129,7 +132,14 @@ private static async Task ReplaceArrayAsync(Container container, hasUsageError |= ShellExitCode.FromException(ex) == ShellExitCode.UsageError; RequestChargeContext.Record(RequestChargeContext.GetCosmosExceptionCharge(ex)); failCount++; - ShellInterpreter.WriteLine(ex.Message); + if (errors != null) + { + errors.Add(MessageService.GetArgsString("error-bulk-item", "index", itemIndex, "message", ex.Message)); + } + else + { + shell.Output.WriteLine(ShellMessageKind.Error, ex.Message); + } } } @@ -149,7 +159,11 @@ private static async Task ReplaceArrayAsync(Container container, if (failCount > 0) { - ShellInterpreter.WriteLine(summary); + if (!shell.IsMachineMode) + { + shell.Output.WriteLine(ShellMessageKind.Error, summary); + } + RequestChargeContext.Record(charge); var message = MessageService.GetArgsString( "command-replace-error-array_failed", @@ -157,6 +171,11 @@ private static async Task ReplaceArrayAsync(Container container, failCount, "total", successCount + failCount); + if (errors != null) + { + message += Environment.NewLine + errors.GetMessage(); + } + throw hasUsageError ? CommandException.Usage("replace", message) : new CommandException("replace", message); diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs index 0c29862c..9c49adf1 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/RmCommand.cs @@ -416,10 +416,7 @@ async Task RemoveMatchesAsync() }); 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); @@ -556,7 +553,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 98d5a768..5c07228b 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, ShellInt { 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, ShellInt 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 2d7767f4..f7c698c7 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 949052cd..72dc04b1 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/SprocCommand.cs @@ -262,11 +262,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")))); @@ -280,7 +280,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; @@ -321,7 +321,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)); @@ -377,17 +377,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", shell)) { 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; } @@ -434,7 +434,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, @@ -465,7 +465,7 @@ internal async Task ExecAsync(Container container, CommandState co response.EnsureSuccessStatusCode(); commandState.RequestCharge = response.Headers.RequestCharge; - ShellInterpreter.WriteLine(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-sproc-executed", "name", name, @@ -490,7 +490,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, @@ -530,7 +530,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; } @@ -539,7 +539,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, @@ -570,7 +570,7 @@ private async Task LaunchEditorAsync(ShellInterpreter shell, string init await File.WriteAllTextAsync(tempPath, initialBody, token); using var pause = shell.PauseOperationTimeout(); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-sproc-edit-launching", "name", name, @@ -601,7 +601,7 @@ private async Task LaunchEditorAsync(ShellInterpreter shell, string init // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.WriteLine(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 ef08d491..5f9a57a9 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThemeCommand.cs @@ -49,31 +49,39 @@ 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)), + "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(commandState)), - "reload" => Task.FromResult(this.RunReload(commandState)), - _ => Task.FromResult(this.RunUnknownAction(commandState, action)), + "open" => Task.FromResult(this.RunOpen(shell, commandState)), + "reload" => Task.FromResult(this.RunReload(shell, commandState)), + _ => 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) + => CommandException.Usage( + "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() { @@ -135,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); - AnsiConsole.MarkupLine(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); @@ -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); + shell.Output.Render(ShellMessageKind.Result, table); } finally { @@ -184,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 = () => AnsiConsole.MarkupLine(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>(); @@ -221,54 +229,52 @@ private CommandState RunList(CommandState commandState) { foreach (var (marker, name, source) in rows) { - AnsiConsole.MarkupLine($" {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)) { - return ReportUnknownTheme(commandState, profileName); + 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)) { - AnsiConsole.MarkupLine(MessageService.GetString("command-theme-use-missing-name")); - return new ErrorCommandState(CommandException.Usage("theme", MessageService.GetString("command-theme-use-missing-name"))); + throw CommandException.Usage("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 = () => 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)) { var message = MessageService.GetString("command-theme-load-missing-path"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(CommandException.Usage("theme", message)); + throw CommandException.Usage("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( + shell.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)); + shell.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -301,24 +307,21 @@ 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); } } - private CommandState RunSave(CommandState commandState) + private CommandState RunSave(ShellInterpreter shell, CommandState commandState) { if (string.IsNullOrWhiteSpace(this.Name)) { var message = MessageService.GetString("command-theme-save-missing-name"); - AnsiConsole.MarkupLine(message); - return new ErrorCommandState(CommandException.Usage("theme", message)); + throw CommandException.Usage("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(CommandException.Usage("theme", message)); + throw CommandException.Usage("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( + shell.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))); + 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 { @@ -374,50 +375,53 @@ 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); } } - 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); - 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 (!shell.IsMachineMode || !strictFailure) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + shell.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) + { + shell.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,12 @@ 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)); + if (shell.IsMachineMode) + { + strictMessage += Environment.NewLine + result.Source + ": " + string.Join(Environment.NewLine, result.Warnings); + } + + throw new CommandException("theme", strictMessage); } commandState.RenderUser = () => { }; @@ -443,24 +451,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) + private CommandState RunValidateDirectory(ShellInterpreter shell, CommandState commandState, string directory) { var files = ThemeFile.EnumerateThemeFiles(directory); + var presentResults = !shell.IsMachineMode; if (files.Length == 0) { var emptyMessage = MessageService.GetArgsString("command-theme-validate-no-files", "directory", directory); - AnsiConsole.MarkupLine(Theme.FormatMuted(emptyMessage)); + if (presentResults) + { + shell.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(emptyMessage)); + } + commandState.RenderUser = () => { }; commandState.Result = new ShellJson(JsonSerializer.SerializeToElement(new { @@ -474,6 +485,8 @@ 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; @@ -496,17 +509,31 @@ 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) + ")")}"); + validationErrors.Add(result.Source + ": " + string.Join(Environment.NewLine, result.Warnings)); + if (presentResults) + { + shell.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) + { + shell.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) { - AnsiConsole.MarkupLine(" " + Theme.FormatWarning(warning)); + foreach (var warning in result.Warnings) + { + shell.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); + } + } + else + { + pendingWarnings.AddRange(result.Warnings); } } catch (Exception ex) when (ex is ThemeLoadException || ex is FileNotFoundException || ex is DirectoryNotFoundException) @@ -514,22 +541,51 @@ 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)); + validationErrors.Add(file + ": " + ex.Message); + if (presentResults) + { + shell.Output.MarkupLine(ShellMessageKind.Error, $" {Theme.FormatError("\u2717")} {Markup.Escape(System.IO.Path.GetFileName(file))}"); + shell.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) + { + var message = MessageService.GetArgsString( + "command-theme-validate-summary", + "valid", + validCount, + "total", + files.Length, + "directory", + directory); + if (!presentResults) + { + message += Environment.NewLine + string.Join(Environment.NewLine, validationErrors); + } + + throw new CommandException("theme", message); + } + + foreach (var warning in pendingWarnings) + { + shell.Output.MarkupLine(ShellMessageKind.Warning, " " + Theme.FormatWarning(warning)); + } + + if (presentResults) + { + shell.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,15 +597,10 @@ private CommandState RunValidateDirectory(CommandState commandState, string dire invalid = invalidCount, })); - if (invalidCount > 0) - { - return new ErrorCommandState(new CommandException("theme", summary)); - } - 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 @@ -572,11 +623,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( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-opened", "path", Markup.Escape(targetPath))); @@ -597,8 +647,7 @@ private CommandState RunEdit(ShellInterpreter shell, 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 +682,10 @@ private CommandState RunEdit(ShellInterpreter shell, 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 +700,11 @@ private CommandState RunEdit(ShellInterpreter shell, 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( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-edit-seeded", "name", Markup.Escape(registration.Name), @@ -668,18 +715,17 @@ private CommandState RunEdit(ShellInterpreter shell, 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( + shell.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-theme-edit-launching", "path", targetPath!, @@ -709,8 +755,7 @@ private CommandState RunEdit(ShellInterpreter shell, 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) @@ -721,8 +766,7 @@ private CommandState RunEdit(ShellInterpreter shell, 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 @@ -736,7 +780,7 @@ private CommandState RunEdit(ShellInterpreter shell, CommandState commandState) // Re-scan the directory too so other files stay in sync with disk. registry.LoadFromDirectory(ThemeFile.DefaultUserThemesDirectory()); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-edit-applied", "name", Markup.Escape(result.Name), @@ -745,7 +789,7 @@ private CommandState RunEdit(ShellInterpreter shell, CommandState commandState) foreach (var warning in result.Warnings) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + shell.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -767,19 +811,18 @@ private CommandState RunEdit(ShellInterpreter shell, 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); } } - 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); - AnsiConsole.MarkupLine(MessageService.GetArgsString( + shell.Output.MarkupLine(ShellMessageKind.Information, MessageService.GetArgsString( "command-theme-reloaded", "count", loaded, @@ -787,7 +830,7 @@ private CommandState RunReload(CommandState commandState) Markup.Escape(directory))); foreach (var warning in registry.Warnings) { - AnsiConsole.MarkupLine(Theme.FormatWarning(warning)); + shell.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(warning)); } commandState.RenderUser = () => { }; @@ -801,16 +844,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(CommandException.Usage("theme", message)); - } } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/ThroughputCommand.cs index 2b1b27dd..f9e4a6f0 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)); + shell.Output.MarkupLine(ShellMessageKind.RequiredInstruction, MessageService.GetArgsString("command-throughput-confirm_summary", "resource", Markup.Escape(resourceName), "mode", modeLabel, "ru", ruText)); return ShellInterpreter.Confirm("command-throughput-confirm", shell); } @@ -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 cb332a72..549c43c2 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", shell)) { 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, @@ -521,7 +521,7 @@ private async Task LaunchEditorAsync(ShellInterpreter shell, string init await File.WriteAllTextAsync(tempPath, initialBody, token); using var pause = shell.PauseOperationTimeout(); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-trigger-edit-launching", "name", name, @@ -552,7 +552,7 @@ private async Task LaunchEditorAsync(ShellInterpreter shell, string init // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.WriteLine(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/TtlCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs index dc65e10b..1ba51162 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/TtlCommand.cs @@ -67,7 +67,7 @@ async Task IStateVisitor.VisitConn string? container = NormalizeOption(this.Container); if (database is not null && container is not null) { - return await this.ExecuteOnContainerAsync(state, database, container, token); + return await this.ExecuteOnContainerAsync(state, shell, database, container, token); } throw new NotInContainerException("ttl"); @@ -80,7 +80,7 @@ async Task IStateVisitor.VisitData if (container is not null) { - return await this.ExecuteOnContainerAsync(state, databaseName, container, token); + return await this.ExecuteOnContainerAsync(state, shell, databaseName, container, token); } throw new NotInContainerException("ttl"); @@ -91,7 +91,7 @@ async Task IStateVisitor.VisitCont string databaseName = NormalizeOption(this.Database) ?? state.DatabaseName; string containerName = NormalizeOption(this.Container) ?? state.ContainerName; - return await this.ExecuteOnContainerAsync(state, databaseName, containerName, token); + return await this.ExecuteOnContainerAsync(state, shell, databaseName, containerName, token); } /// @@ -173,7 +173,7 @@ view.AnalyticalTimeToLiveSeconds is null or 0 }; } - private async Task ExecuteOnContainerAsync(ConnectedState state, string databaseName, string containerName, CancellationToken token) + private async Task ExecuteOnContainerAsync(ConnectedState state, ShellInterpreter shell, string databaseName, string containerName, CancellationToken token) { // Validate the subcommand and its arguments before touching the network so bad // input fails fast without a round-trip. @@ -218,7 +218,7 @@ private async Task ExecuteOnContainerAsync(ConnectedState state, s var analyticalView = await CosmosResourceFacade.ReplaceAnalyticalTimeToLiveAsync(state, databaseName, containerName, targetTtl, token); var analyticalResult = BuildAnalyticalResult(containerName, analyticalView); - analyticalResult.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-ttl-analytical-updated")); + analyticalResult.RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-ttl-analytical-updated")); return analyticalResult; } @@ -230,7 +230,7 @@ private async Task ExecuteOnContainerAsync(ConnectedState state, s var view = await CosmosResourceFacade.ReplaceTimeToLiveAsync(state, databaseName, containerName, targetTtl, token); var result = BuildResult(containerName, view); - result.RenderUser = () => ShellInterpreter.WriteLine(MessageService.GetString("command-ttl-updated")); + result.RenderUser = () => shell.Output.WriteLine(ShellMessageKind.Result, MessageService.GetString("command-ttl-updated")); return result; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/UdfCommand.cs index 5531977e..715f61be 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", shell)) { 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, @@ -440,7 +440,7 @@ private async Task LaunchEditorAsync(ShellInterpreter shell, string init await File.WriteAllTextAsync(tempPath, initialBody, token); using var pause = shell.PauseOperationTimeout(); - AnsiConsole.MarkupLine(Theme.FormatMuted(MessageService.GetArgsString( + ShellInterpreter.Instance.Output.MarkupLine(ShellMessageKind.Information, Theme.FormatMuted(MessageService.GetArgsString( "command-udf-edit-launching", "name", name, @@ -471,7 +471,7 @@ private async Task LaunchEditorAsync(ShellInterpreter shell, string init // before reading the file back. if (launched.Elapsed < QuickEditorExit) { - ShellInterpreter.WriteLine(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.Commands/WatchCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Commands/WatchCommand.cs index 4a384550..f7bf3912 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 CommandException.Usage("watch", MessageService.GetString("command-watch-error-machine_max_required")); + } + if (shell.State is not ConnectedState connectedState) { throw new NotConnectedException("watch"); @@ -161,7 +166,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 foreach (var element in documents) { token.ThrowIfCancellationRequested(); - if (!redirected) + if (!redirected && (collected == null || !shell.IsMachineMode)) { - AnsiConsole.MarkupLine(JsonOutputHighlighter.BuildMarkup(element)); + shell.Output.MarkupLine(ShellMessageKind.Result, JsonOutputHighlighter.BuildMarkup(element)); } collected?.Add(element); @@ -222,7 +227,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 { UserCancellationHandled = token.IsCancellationRequested }; 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 +/// Collects per-item bulk-write failures for the single machine-mode error payload. +/// Count and length are bounded so large failing writes do not retain every diagnostic. +/// +internal sealed class BulkWriteDiagnostics +{ + 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("error-bulk-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( + "error-bulk-diagnostics_omitted", + "count", + this.omittedCount); + } + + return message; + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompletionRenderer.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompletionRenderer.cs index e505ccfb..d2259c79 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompletionRenderer.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/CosmosCompletionRenderer.cs @@ -12,6 +12,7 @@ internal class CosmosCompletionRenderer(ShellInterpreter shellInterpreter) : ILi { private readonly ShellInterpreter shellInterpreter = shellInterpreter; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CZ0003:Direct console output", Justification = "Inline completion hints are drawn only inside the interactive RadLine editor.")] void ILineDecorationRenderer.RenderLineDecoration(LineBuffer buffer) { var word = buffer.Content; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ErrorRedirectWarningScope.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ErrorRedirectWarningScope.cs index ceb2cce3..cc19b0c1 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ErrorRedirectWarningScope.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ErrorRedirectWarningScope.cs @@ -11,4 +11,12 @@ namespace Azure.Data.Cosmos.Shell.Core; internal sealed class ErrorRedirectWarningScope { public bool HasWarning { get; set; } + + /// + /// Gets or sets a value indicating whether shell output (warnings, errors, or required + /// instructions) was written to this destination, so a later machine-mode error appends. + /// + public bool HasOutput { get; set; } + + public bool HasContent => this.HasWarning || this.HasOutput; } diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs index 758e960b..b778ad9d 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/OutputPolicy.cs @@ -13,6 +13,22 @@ namespace Azure.Data.Cosmos.Shell.Core; /// 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)), + }; + + // 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. /// and are structured diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 48dec3c0..2bb18499 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -79,6 +79,8 @@ public partial class ShellInterpreter : IDisposable private readonly SemaphoreSlim executionGate = new(1, 1); + private readonly TextWriter errorRedirectWriter; + private long stateVersion; private OperationInactivityTimeout? localEmulatorDeadline; @@ -119,6 +121,12 @@ public partial class ShellInterpreter : IDisposable internal ShellInterpreter(string? configPath = null) { this.State = new DisconnectedState(); + this.errorRedirectWriter = new ErrorRedirectWriter(this); + this.Output = new ShellOutput( + () => this.Options?.Quiet == true, + () => this.IsMachineMode, + standardError: () => this.ErrOutRedirect != null ? this.errorRedirectWriter : Console.Error, + errorRedirected: () => this.ErrOutRedirect != null); // editor.KeyBindings.Add(ConsoleKey.Escape); // TODO: Support selection commands? @@ -163,6 +171,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. /// @@ -452,83 +462,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); } /// @@ -554,7 +539,7 @@ internal static bool Confirm(string message, ShellInterpreter shell, Func 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() @@ -1713,7 +1696,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; }, }; @@ -2236,7 +2219,7 @@ internal void WriteTransientLine(string text, string? compactText = null) } } - Write("\r" + text + new string(' ', Math.Max(0, this.transientLineLength - text.Length))); + this.Output.Write(ShellMessageKind.Progress, "\r" + text + new string(' ', Math.Max(0, this.transientLineLength - text.Length))); this.transientLineLength = text.Length; } } @@ -2254,7 +2237,7 @@ internal void ClearTransientLine() return; } - Write("\r" + new string(' ', this.transientLineLength) + "\r"); + this.Output.Write(ShellMessageKind.Progress, "\r" + new string(' ', this.transientLineLength) + "\r"); this.transientLineLength = 0; } } @@ -2293,13 +2276,13 @@ internal void PrintCommand(string cmdString) 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(SecretRedactor.MaskHistory(cmdString)); } catch (NotSupportedException) { - Console.Out.WriteLine(cmdString); + this.Output.WriteLine(ShellMessageKind.Information, cmdString); } } @@ -2461,11 +2444,11 @@ private CommandState PrintStateCore(CommandState state, bool markAsRendered) if (hasLongLine || jsonText.Length - lineStart >= AnsiConsole.Console.Profile.Width) { - Console.Out.WriteLine(jsonText); + this.Output.WriteLine(ShellMessageKind.Result, jsonText); } else { - AnsiConsole.MarkupLine(JsonOutputHighlighter.BuildMarkup(element.Value)); + this.Output.MarkupLine(ShellMessageKind.Result, JsonOutputHighlighter.BuildMarkup(element.Value)); } return state; @@ -2486,7 +2469,7 @@ private CommandState PrintStateCore(CommandState state, bool markAsRendered) && output != null && state.Result is ShellText { Highlighter: { } highlighter }) { - AnsiConsole.MarkupLine(highlighter(output)); + this.Output.MarkupLine(ShellMessageKind.Result, highlighter(output)); return state; } } @@ -2495,7 +2478,7 @@ private CommandState PrintStateCore(CommandState state, bool markAsRendered) { if (!redirected) { - Console.Out.WriteLine(output); + this.Output.WriteResult(output); } else { @@ -2507,7 +2490,7 @@ private CommandState PrintStateCore(CommandState state, bool markAsRendered) { if (this.Options?.Verbose == true && !this.IsMachineMode) { - AnsiConsole.WriteException(e); + this.Output.WriteException(ShellMessageKind.Error, e); return new ErrorCommandState(e); } @@ -2527,7 +2510,7 @@ private CommandState PrintStateCore(CommandState state, bool markAsRendered) } else if (!string.IsNullOrEmpty(canceled)) { - AnsiConsole.MarkupLine(Theme.FormatWarning(canceled)); + this.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(canceled)); } return new ErrorCommandState(e) { OutputRendered = inMachineMode }; @@ -2540,16 +2523,16 @@ private CommandState PrintStateCore(CommandState state, bool markAsRendered) 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; } } @@ -2682,7 +2665,7 @@ private void RenderWarnings( if (machineModeWarningRecords && (!warning.IsRendered || (renderUpdates && warning.EmittedAsMachineModeWarningRecord))) { - Console.Error.WriteLine(JsonSerializer.Serialize(new + ShellOutput.StandardError.WriteLine(ShellMessageKind.Warning, JsonSerializer.Serialize(new { status = renderUpdates && warning.EmittedAsMachineModeWarningRecord ? "warning-update" : "warning", warning = warning.Message, @@ -2697,7 +2680,7 @@ private void RenderWarnings( continue; } - Console.Error.WriteLine(warning.Message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Warning, warning.Message); } else if (this.ErrOutRedirect != null) { @@ -2706,11 +2689,11 @@ private void RenderWarnings( } else if (!redirected && !inMachineMode) { - AnsiConsole.MarkupLine(Theme.FormatWarning(Markup.Escape(warning.Message))); + this.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(Markup.Escape(warning.Message))); } else { - Console.Error.WriteLine(warning.Message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Warning, warning.Message); } warning.IsRendered = true; @@ -2847,7 +2830,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, @@ -2855,7 +2838,7 @@ private void PrintSessionRequestChargeWarning(double requestCharge, double warni } else { - AnsiConsole.MarkupLine(Theme.FormatWarning(message)); + this.Output.MarkupLine(ShellMessageKind.Warning, Theme.FormatWarning(message)); } } @@ -2885,7 +2868,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(); } @@ -2952,7 +2935,7 @@ internal static CosmosClientOptions CreateClientOptions(ConnectionMode requested } catch (Exception e) { - Console.Error.WriteLine(e.Message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, e.Message); return null; } } @@ -3471,13 +3454,14 @@ private void WriteMachineError( // Warnings deferred while stderr was the console belong there, not in this file. foreach (var warning in warnings.Where(warning => !warning.IsRendered && warning.DeferredToConsole)) { - Console.Error.WriteLine(warning.Message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Warning, warning.Message); warning.IsRendered = true; warning.LastRenderedMessage = warning.Message; } } - var replacedRedirectPath = this.ErrOutRedirect != null && !this.AppendErrRedirection + var appendToErrorRedirect = this.AppendErrRedirection || this.ErrorRedirectWarnings.HasOutput; + var replacedRedirectPath = this.ErrOutRedirect != null && !appendToErrorRedirect ? Path.GetFullPath(this.ErrOutRedirect) : null; var outputWarnings = warnings?.Where(warning => @@ -3500,7 +3484,7 @@ private void WriteMachineError( if (this.ErrOutRedirect != null) { var payload = json + Environment.NewLine; - if (this.AppendErrRedirection) + if (appendToErrorRedirect) { File.AppendAllText(this.ErrOutRedirect, payload); } @@ -3511,7 +3495,7 @@ private void WriteMachineError( } else { - Console.Error.WriteLine(json); + this.Output.WriteLine(ShellMessageKind.Error, json); } foreach (var warning in outputWarnings) @@ -3537,7 +3521,7 @@ private void WriteErrorRedirect(string text) throw new InvalidOperationException("Cannot redirect error output without an error redirection path."); } - if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasWarning) + if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasContent) { File.AppendAllText(this.ErrOutRedirect, text); } @@ -3568,7 +3552,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), @@ -3584,20 +3568,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( @@ -3658,7 +3642,7 @@ private void ReportExecutionError( : prefix + e.Message + (!string.IsNullOrEmpty(hint) ? Environment.NewLine + hint : string.Empty) + (showInner ? Environment.NewLine + FormatInnerExceptionMessages(e.InnerException) : string.Empty); - if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasWarning) + if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasContent) { File.AppendAllText(this.ErrOutRedirect, errTxt); } @@ -3671,20 +3655,20 @@ private void ReportExecutionError( { 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) @@ -3692,7 +3676,7 @@ private void ReportExecutionError( 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; } } @@ -3727,7 +3711,7 @@ private void ReportCommandNotFoundError(CommandNotFoundException e, string sourc } else { - AnsiConsole.MarkupLine(Markup.Escape(e.Hint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(e.Hint)); } } @@ -3747,7 +3731,7 @@ private void ReportCommandNotFoundError(CommandNotFoundException e, string sourc } else { - AnsiConsole.MarkupLine(Markup.Escape(csHint)); + this.Output.MarkupLine(ShellMessageKind.Error, Markup.Escape(csHint)); } } } @@ -3755,7 +3739,7 @@ private void ReportCommandNotFoundError(CommandNotFoundException e, string sourc if (fileBuffer != null) { var payload = fileBuffer.ToString(); - if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasWarning) + if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasContent) { File.AppendAllText(this.ErrOutRedirect!, payload); } @@ -3797,7 +3781,7 @@ private void ReportPositionalError(PositionalException pe) errorMessage += Environment.NewLine + string.Join(Environment.NewLine, callTrace); } - if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasWarning) + if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasContent) { File.AppendAllText(this.ErrOutRedirect, errorMessage); } @@ -3808,16 +3792,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)); } } } @@ -3943,7 +3927,7 @@ private void ReportParserErrors( if (redirected && fileBuffer != null) { var payload = fileBuffer.ToString(); - if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasWarning) + if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasContent) { File.AppendAllText(this.ErrOutRedirect!, payload); } @@ -3995,7 +3979,7 @@ internal bool TryReportQueryError(string query, string rawMessage) if (fileBuffer != null) { var payload = fileBuffer.ToString(); - if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasWarning) + if (this.AppendErrRedirection || this.ErrorRedirectWarnings.HasContent) { File.AppendAllText(this.ErrOutRedirect!, payload); } @@ -4053,16 +4037,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)}"); } } @@ -4141,6 +4125,30 @@ private static string FormatInnerExceptionMessages(Exception? exception) return sb.ToString(); } + /// + /// Routes stderr writes to the active 2>/2>> + /// target. The first write in a redirect scope honors overwrite; later writes append. + /// + private sealed class ErrorRedirectWriter(ShellInterpreter shell) : TextWriter + { + public override System.Text.Encoding Encoding => System.Text.Encoding.UTF8; + + public override void Write(char value) => this.Write(value.ToString()); + + public override void Write(char[] buffer, int index, int count) => this.Write(new string(buffer, index, count)); + + public override void Write(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + + shell.WriteErrorRedirect(value); + shell.ErrorRedirectWarnings.HasOutput = true; + } + } + /* private void PrintReadmeSection(string cmdStr) { 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..cf8cc3ef --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellOutput.cs @@ -0,0 +1,225 @@ +// ------------------------------------------------------------ +// 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, + Func? errorRedirected = 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 bool UseStandardError(ShellMessageKind kind) + => OutputPolicy.UseStandardError(kind, machineMode(), errorRedirected?.Invoke() == true); + + private TextWriter GetWriter(ShellMessageKind kind) + => this.UseStandardError(kind) + ? standardError?.Invoke() ?? Console.Error + : standardOutput?.Invoke() ?? Console.Out; + + private IAnsiConsole GetConsole(ShellMessageKind kind) + { + if (!machineMode() && !this.UseStandardError(kind)) + { + 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), + + // CI enrichers (e.g. GitHub Actions) would otherwise re-enable ANSI escapes. + Enrichment = new ProfileEnrichment { UseDefaultEnrichers = false }, + }); + } + + 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 38801031..411699ac 100644 --- a/CosmosDBShell/Program.cs +++ b/CosmosDBShell/Program.cs @@ -80,7 +80,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; } @@ -115,10 +115,10 @@ public static async Task Main(string[] args) { foreach (var error in GetParseErrorMessages(parseResult)) { - ShellInterpreter.WriteLine(error); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, error); } - ShellInterpreter.WriteLine(BuildHelpText()); + ShellInterpreter.Instance.Output.WriteLine(ShellMessageKind.Result, BuildHelpText()); } Environment.ExitCode = ShellExitCode.UsageError; @@ -200,7 +200,7 @@ public static async Task Main(string[] args) } else { - ShellInterpreter.WriteLine(msg); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, msg); } return; @@ -232,7 +232,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; @@ -263,7 +263,7 @@ void WriteStartupError(string message) return; } - AnsiConsole.WriteLine(message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, message); } if (o.Container != null && o.Database == null) @@ -315,22 +315,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) @@ -541,7 +541,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) @@ -837,7 +837,7 @@ internal static IReadOnlyList GetParseErrorMessages(System.CommandLine.P private static void WriteErrorLine(string message) { - Console.Error.WriteLine(message); + ShellOutput.StandardError.WriteLine(ShellMessageKind.Error, message); } private static string BuildHelpText() @@ -913,9 +913,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` @@ -925,10 +925,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) @@ -942,15 +939,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 b24149a2..a194ff12 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -177,6 +177,9 @@ 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-bulk-diagnostic_truncated = [diagnostic truncated] +error-bulk-diagnostics_omitted = { $count } additional item diagnostics omitted. 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) @@ -212,7 +215,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. help-topics = Help Topics help-topics-hint = Type help to show a topic. help-topic-shortcuts-description = Keyboard shortcuts for the interactive prompt @@ -940,7 +943,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. Use { "{" }{ "}" } for an undefined 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. @@ -949,6 +952,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 d5af6f2f..41b82fd5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -369,6 +369,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 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 ### query @@ -977,6 +979,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. + On a local emulator, intentional waits between polls do not count toward the 10-second inactivity timeout. A stalled request still times out and disconnects; Ctrl+C remains a normal way to stop watching. ```bash diff --git a/docs/navigation.md b/docs/navigation.md index 32071f0c..e6ec722b 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -302,7 +302,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 | @@ -337,11 +337,24 @@ banners, emits command results as the selected structured format (JSON or CSV) o and writes parser, connection, and command failures (including `theme` errors and rejected non-interactive `edit` calls) as a structured `{ "status": "error", "error": ... }` object on `STDERR`. In-shell `2>` / `2>>` redirection sends that error object to the specified -file instead. Inferred partition-key mutation warnings are emitted before writes as +file instead. `STDOUT` carries only the command result; tables and other interactive +views are not rendered. Inferred partition-key mutation warnings are emitted before writes as JSON Lines records on console stderr; a later error is emitted as a separate JSON record. 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`. +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. +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. + In normal `user` mode, command confirmations and status messages are readable text, not JSON status objects. Commands that display document or policy data can still show that data as JSON. Where supported, a command's `--format` / `-f` selects its result diff --git a/l10n/CosmosDBShell.json b/l10n/CosmosDBShell.json index c12c734a..53f273cf 100644 --- a/l10n/CosmosDBShell.json +++ b/l10n/CosmosDBShell.json @@ -1254,9 +1254,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. Use {} for an undefined 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", @@ -1311,6 +1312,9 @@ "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-diagnostic_truncated": "[diagnostic truncated]", + "error-bulk-diagnostics_omitted": "{0} additional item diagnostics omitted.", + "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?", @@ -1439,7 +1443,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}",