diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index e7633c4..93b0b16 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -7,6 +7,12 @@
"commands": [
"nbgv"
]
+ },
+ "dotnet-reportgenerator-globaltool": {
+ "version": "5.5.10",
+ "commands": [
+ "reportgenerator"
+ ]
}
}
}
\ No newline at end of file
diff --git a/.github/workflows/validate-and-package.yml b/.github/workflows/validate-and-package.yml
index 0ef5c5b..01a037d 100644
--- a/.github/workflows/validate-and-package.yml
+++ b/.github/workflows/validate-and-package.yml
@@ -168,9 +168,44 @@ jobs:
--no-restore
--logger "trx;LogFileName=test-results.trx"
--results-directory TestResults
- --collect "Code coverage"
+ --collect "XPlat Code Coverage"
shell: pwsh
+ - name: Generate code coverage report
+ if: always()
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+
+ $coverageFiles = Get-ChildItem -Path TestResults -Recurse -Filter 'coverage.cobertura.xml' -ErrorAction SilentlyContinue
+ if (-not $coverageFiles) {
+ Write-Host '::warning::No coverage files found; skipping coverage report.'
+ return
+ }
+
+ $reports = ($coverageFiles.FullName -join ';')
+ dotnet tool run reportgenerator `
+ "-reports:$reports" `
+ "-targetdir:TestResults/coverage" `
+ "-reporttypes:Html;MarkdownSummaryGithub;TextSummary" `
+ "-title:CosmosDBShell Code Coverage"
+ if ($LASTEXITCODE -ne 0) {
+ throw "reportgenerator failed with exit code $LASTEXITCODE."
+ }
+
+ $markdown = 'TestResults/coverage/SummaryGithub.md'
+ if (Test-Path $markdown) {
+ Get-Content $markdown | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append
+ }
+
+ - name: Upload code coverage report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: code-coverage-report
+ path: TestResults/coverage
+ if-no-files-found: ignore
+
- name: Run fuzzer smoke test
working-directory: CosmosDBShell.Fuzzer
run: dotnet run --configuration $env:BUILD_CONFIGURATION --no-build --no-restore -- --all
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 623de88..3a387b7 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -18,6 +18,24 @@
]
},
"problemMatcher": "$msCompile"
+ },
+ {
+ "label": "coverage",
+ "detail": "Run unit tests with code coverage and generate a per-namespace report.",
+ "command": "pwsh",
+ "type": "process",
+ "args": [
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ "${workspaceFolder}/tools/coverage.ps1"
+ ],
+ "windows": {
+ "command": "powershell"
+ },
+ "group": "test",
+ "problemMatcher": []
}
]
}
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3091c8f..5000a0e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -22,6 +22,7 @@ There are several ways you can contribute to the CosmosDBShell project:
- Restore dependencies: `dotnet restore CosmosDBShell.sln`
- Build: `dotnet build CosmosDBShell.sln` (or use the VS Code build task with Ctrl+Shift+B).
- Run tests: `dotnet test CosmosDBShell.sln`
+ - Run tests with code coverage: `./tools/coverage.ps1` (or the VS Code `coverage` task). Generates an HTML report under `TestResults/coverage` and prints a per-namespace summary; use the report's "Group by" selector for namespace-level coverage.
- Run the tool locally: `dotnet run --project CosmosDBShell/CosmosDBShell.csproj`
- GitHub Actions runs CI and uploads NuGet package artifacts from [.github/workflows/validate-and-package.yml](.github/workflows/validate-and-package.yml).
- Local builds and GitHub Actions use the default NuGet sources (nuget.org). The Azure DevOps pipeline uses [.pipelines/nuget.config](.pipelines/nuget.config) to restrict restores to the internal feed.
diff --git a/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs
new file mode 100644
index 0000000..cfcc890
--- /dev/null
+++ b/CosmosDBShell.Tests/CommandTests/BucketCommandTests.cs
@@ -0,0 +1,120 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.CommandTests;
+
+using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.States;
+using Azure.Data.Cosmos.Shell.Util;
+using Microsoft.Azure.Cosmos;
+
+///
+/// Unit tests for . Covers the pure validation helper and
+/// the offline state visitors that do not require a live Cosmos DB connection.
+///
+public class BucketCommandTests
+{
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(5)]
+ public void CheckBucket_WithinRange_ReturnsTrue(int bucket)
+ {
+ Assert.True(BucketCommand.CheckBucket(bucket));
+ }
+
+ [Theory]
+ [InlineData(-1)]
+ [InlineData(6)]
+ [InlineData(99)]
+ public void CheckBucket_OutOfRange_ReturnsFalse(int bucket)
+ {
+ Assert.False(BucketCommand.CheckBucket(bucket));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new BucketCommand { Bucket = 3 };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "bucket 3", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Connected_ThrowsNotInDatabase()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new ConnectedState(CreateTestClient());
+ var command = new BucketCommand { Bucket = 3 };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "bucket 3", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_InDatabase_SetsBucket()
+ {
+ var client = CreateTestClient();
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DatabaseState("TestDatabase", client);
+ var command = new BucketCommand { Bucket = 3 };
+
+ var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 3", CancellationToken.None);
+
+ Assert.False(state.IsError);
+ Assert.Equal(3, client.ClientOptions.ThroughputBucket);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_InDatabase_ZeroResetsBucket()
+ {
+ var client = CreateTestClient();
+ client.ClientOptions.ThroughputBucket = 4;
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DatabaseState("TestDatabase", client);
+ var command = new BucketCommand { Bucket = 0 };
+
+ var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 0", CancellationToken.None);
+
+ Assert.False(state.IsError);
+ Assert.Null(client.ClientOptions.ThroughputBucket);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_InContainer_NoArgs_ShowsCurrent()
+ {
+ var client = CreateTestClient();
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new ContainerState("TestContainer", "TestDatabase", client);
+ var command = new BucketCommand();
+
+ var state = await command.ExecuteAsync(shell, new CommandState(), "bucket", CancellationToken.None);
+
+ Assert.False(state.IsError);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_InDatabase_InvalidValue_ReturnsEmptyState()
+ {
+ var client = CreateTestClient();
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DatabaseState("TestDatabase", client);
+ var command = new BucketCommand { Bucket = 99 };
+
+ var state = await command.ExecuteAsync(shell, new CommandState(), "bucket 99", CancellationToken.None);
+
+ Assert.False(state.IsError);
+ Assert.Null(client.ClientOptions.ThroughputBucket);
+ }
+
+ private static CosmosClient CreateTestClient()
+ {
+ var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/");
+ return new CosmosClient(connectionString);
+ }
+}
diff --git a/CosmosDBShell.Tests/CommandTests/CatCommandTests.cs b/CosmosDBShell.Tests/CommandTests/CatCommandTests.cs
new file mode 100644
index 0000000..9b98d35
--- /dev/null
+++ b/CosmosDBShell.Tests/CommandTests/CatCommandTests.cs
@@ -0,0 +1,97 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.CommandTests;
+
+using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+using Azure.Data.Cosmos.Shell.States;
+
+///
+/// Unit tests for . The command reads a file from disk and
+/// returns its contents as a result, throwing when the file
+/// path is missing or does not exist. These paths run without a Cosmos DB connection.
+///
+public class CatCommandTests : IDisposable
+{
+ private readonly string tempFile;
+
+ public CatCommandTests()
+ {
+ this.tempFile = Path.Combine(Path.GetTempPath(), $"catcmd_{Guid.NewGuid():N}.txt");
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(this.tempFile))
+ {
+ File.Delete(this.tempFile);
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ExistingFile_ReturnsContentsAsShellText()
+ {
+ const string contents = "{ \"hello\": \"world\" }\nsecond line";
+ await File.WriteAllTextAsync(this.tempFile, contents, TestContext.Current.CancellationToken);
+
+ using var shell = ShellInterpreter.CreateInstance();
+ var command = new CatCommand { FilePath = this.tempFile };
+
+ var state = await command.ExecuteAsync(shell, new CommandState(), "cat", TestContext.Current.CancellationToken);
+
+ var text = Assert.IsType(state.Result);
+ Assert.Equal(contents, text.Text);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_EmptyFile_ReturnsEmptyShellText()
+ {
+ await File.WriteAllTextAsync(this.tempFile, string.Empty, TestContext.Current.CancellationToken);
+
+ using var shell = ShellInterpreter.CreateInstance();
+ var command = new CatCommand { FilePath = this.tempFile };
+
+ var state = await command.ExecuteAsync(shell, new CommandState(), "cat", TestContext.Current.CancellationToken);
+
+ var text = Assert.IsType(state.Result);
+ Assert.Equal(string.Empty, text.Text);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NonExistentFile_ThrowsCommandException()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ var command = new CatCommand { FilePath = this.tempFile };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "cat", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NullPath_ThrowsCommandException()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ var command = new CatCommand { FilePath = null };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "cat", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task StateVisitors_ReturnZero()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ var command = new CatCommand();
+
+ var ct = TestContext.Current.CancellationToken;
+ Assert.Equal(0, await command.VisitConnectedStateAsync(null!, string.Empty, ct));
+ Assert.Equal(0, await command.VisitContainerStateAsync(null!, string.Empty, ct));
+ Assert.Equal(0, await command.VisitDatabaseStateAsync(null!, string.Empty, ct));
+ Assert.Equal(0, await command.VisitDisconnectedStateAsync(null!, string.Empty, ct));
+ }
+}
diff --git a/CosmosDBShell.Tests/CommandTests/ContainerScopedCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ContainerScopedCommandTests.cs
new file mode 100644
index 0000000..2e6f051
--- /dev/null
+++ b/CosmosDBShell.Tests/CommandTests/ContainerScopedCommandTests.cs
@@ -0,0 +1,105 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.CommandTests;
+
+using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.States;
+using Azure.Data.Cosmos.Shell.Util;
+using Microsoft.Azure.Cosmos;
+
+///
+/// Offline unit tests for , ,
+/// and . These cover the not-connected and wrong-scope branches
+/// that execute before any network or external-process call.
+///
+public class ContainerScopedCommandTests
+{
+ [Fact]
+ public async Task IndexPolicy_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new IndexPolicyCommand();
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "indexpolicy", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task IndexPolicy_Connected_NoTarget_ThrowsNotInContainer()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new ConnectedState(CreateTestClient());
+ var command = new IndexPolicyCommand();
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "indexpolicy", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task IndexPolicy_InDatabase_NoContainer_ThrowsNotInContainer()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DatabaseState("TestDatabase", CreateTestClient());
+ var command = new IndexPolicyCommand();
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "indexpolicy", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task RmContainer_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new RmContainerCommand { Name = "MyContainer", Force = true };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "rmcon MyContainer true", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task RmContainer_Connected_NoDatabase_ThrowsNotInDatabase()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new ConnectedState(CreateTestClient());
+ var command = new RmContainerCommand { Name = "MyContainer", Force = true };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "rmcon MyContainer true", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task RmContainer_InContainer_NoDatabaseOption_ThrowsNotInContainer()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new ContainerState("TestContainer", "TestDatabase", CreateTestClient());
+ var command = new RmContainerCommand { Name = "MyContainer", Force = true };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "rmcon MyContainer true", CancellationToken.None));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task Edit_MissingPath_ThrowsCommandException(string? path)
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ var command = new EditCommand { FilePath = path };
+
+ var ex = await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "edit", CancellationToken.None));
+ Assert.Equal(MessageService.GetString("command-edit-missing-path"), ex.Message);
+ }
+
+ private static CosmosClient CreateTestClient()
+ {
+ var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/");
+ return new CosmosClient(connectionString);
+ }
+}
diff --git a/CosmosDBShell.Tests/CommandTests/DeleteCommandTests.cs b/CosmosDBShell.Tests/CommandTests/DeleteCommandTests.cs
new file mode 100644
index 0000000..9a92147
--- /dev/null
+++ b/CosmosDBShell.Tests/CommandTests/DeleteCommandTests.cs
@@ -0,0 +1,79 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.CommandTests;
+
+using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.States;
+using Azure.Data.Cosmos.Shell.Util;
+using Microsoft.Azure.Cosmos;
+
+///
+/// Unit tests for . Covers the offline routing branches:
+/// the invalid item-type error and the not-connected paths of the routed commands.
+///
+public class DeleteCommandTests
+{
+ [Theory]
+ [InlineData("bogus")]
+ [InlineData("")]
+ [InlineData("itemz")]
+ public async Task ExecuteAsync_InvalidItemType_ThrowsCommandException(string item)
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new DeleteCommand { Item = item, Pattern = "x" };
+
+ var ex = await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), $"delete {item} x", CancellationToken.None));
+ Assert.Equal(MessageService.GetString("command-delete-error-invalid_item_type"), ex.Message);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Database_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new DeleteCommand { Item = "database", Pattern = "MyDb" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "delete database MyDb", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Container_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new DeleteCommand { Item = "container", Pattern = "MyContainer" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "delete container MyContainer", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Item_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new DeleteCommand { Item = "item", Pattern = "test-*" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "delete item test-*", CancellationToken.None));
+ }
+
+ [Fact]
+ public void CreateCommand_ItemTypeClassifiers_MatchExpected()
+ {
+ Assert.True(CreateCommand.IsItem("ITEM"));
+ Assert.True(CreateCommand.IsItem("I"));
+ Assert.True(CreateCommand.IsContainer("CONTAINER"));
+ Assert.True(CreateCommand.IsContainer("C"));
+ Assert.True(CreateCommand.IsDatabase("DATABASE"));
+ Assert.True(CreateCommand.IsDatabase("DB"));
+ Assert.False(CreateCommand.IsItem("CONTAINER"));
+ Assert.False(CreateCommand.IsDatabase("ITEM"));
+ }
+}
diff --git a/CosmosDBShell.Tests/CommandTests/ItemWriteCommandTests.cs b/CosmosDBShell.Tests/CommandTests/ItemWriteCommandTests.cs
new file mode 100644
index 0000000..e152aba
--- /dev/null
+++ b/CosmosDBShell.Tests/CommandTests/ItemWriteCommandTests.cs
@@ -0,0 +1,124 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.CommandTests;
+
+using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.States;
+using Azure.Data.Cosmos.Shell.Util;
+using Microsoft.Azure.Cosmos;
+
+///
+/// Offline unit tests for the item write commands (,
+/// , ). These cover the
+/// argument validation and not-connected branches that execute before any network call.
+///
+public class ItemWriteCommandTests
+{
+ [Fact]
+ public async Task Print_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new PrintCommand { Id = "item-1", PartitionKey = "pk-1" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "print item-1 pk-1", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Replace_NoInputData_ThrowsCommandException()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new ReplaceCommand();
+
+ var ex = await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "replace", CancellationToken.None));
+ Assert.Equal(MessageService.GetString("error-no_input_data"), ex.Message);
+ }
+
+ [Fact]
+ public async Task Replace_WithData_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new ReplaceCommand { Data = "{\"id\":\"1\"}" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "replace '{\"id\":\"1\"}'", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Patch_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new PatchCommand { Op = "set", Id = "1", Key = "1", Path = "/a", Value = "b" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "patch set 1 1 /a b", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Patch_MissingId_ThrowsCommandException()
+ {
+ var command = new PatchCommand { Op = "set", Id = null, Key = "1", Path = "/a", Value = "b" };
+
+ var ex = await ExecutePatchInDatabaseAsync(command);
+ Assert.Equal(MessageService.GetString("command-patch-error-missing_id"), ex.Message);
+ }
+
+ [Fact]
+ public async Task Patch_MissingKey_ThrowsCommandException()
+ {
+ var command = new PatchCommand { Op = "set", Id = "1", Key = null, Path = "/a", Value = "b" };
+
+ var ex = await ExecutePatchInDatabaseAsync(command);
+ Assert.Equal(MessageService.GetString("command-patch-error-missing_pk"), ex.Message);
+ }
+
+ [Fact]
+ public async Task Patch_MissingOp_ThrowsCommandException()
+ {
+ var command = new PatchCommand { Op = null, Id = "1", Key = "1", Path = "/a", Value = "b" };
+
+ var ex = await ExecutePatchInDatabaseAsync(command);
+ Assert.Equal(MessageService.GetString("command-patch-error-missing_op"), ex.Message);
+ }
+
+ [Fact]
+ public async Task Patch_UnsupportedOp_ThrowsCommandException()
+ {
+ var command = new PatchCommand { Op = "frobnicate", Id = "1", Key = "1", Path = "/a", Value = "b" };
+
+ var ex = await ExecutePatchInDatabaseAsync(command);
+ Assert.Contains("frobnicate", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task Patch_InvalidPath_ThrowsCommandException()
+ {
+ var command = new PatchCommand { Op = "set", Id = "1", Key = "1", Path = "no-leading-slash", Value = "b" };
+
+ var ex = await ExecutePatchInDatabaseAsync(command);
+ Assert.Equal(MessageService.GetString("command-patch-error-invalid_path"), ex.Message);
+ }
+
+ private static async Task ExecutePatchInDatabaseAsync(PatchCommand command)
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DatabaseState("TestDatabase", CreateTestClient());
+
+ return await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "patch", CancellationToken.None));
+ }
+
+ private static CosmosClient CreateTestClient()
+ {
+ var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/");
+ return new CosmosClient(connectionString);
+ }
+}
diff --git a/CosmosDBShell.Tests/CommandTests/MakeDbCommandTests.cs b/CosmosDBShell.Tests/CommandTests/MakeDbCommandTests.cs
new file mode 100644
index 0000000..4fb3209
--- /dev/null
+++ b/CosmosDBShell.Tests/CommandTests/MakeDbCommandTests.cs
@@ -0,0 +1,90 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.CommandTests;
+
+using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.States;
+using Azure.Data.Cosmos.Shell.Util;
+using Microsoft.Azure.Cosmos;
+
+///
+/// Unit tests for . Covers the pure throughput helper and
+/// the disallowed-state visitors, all of which run without a live Cosmos DB connection.
+///
+public class MakeDbCommandTests
+{
+ [Theory]
+ [InlineData("manual")]
+ [InlineData("MANUAL")]
+ [InlineData("m")]
+ [InlineData("M")]
+ public void CreateThroughputProperties_Manual_UsesManualThroughput(string scale)
+ {
+ var properties = MakeDbCommand.CreateThroughputProperties(scale, 1500);
+
+ Assert.Equal(1500, properties.Throughput);
+ }
+
+ [Theory]
+ [InlineData("auto")]
+ [InlineData(null)]
+ [InlineData("something-else")]
+ public void CreateThroughputProperties_NonManual_UsesAutoscaleThroughput(string? scale)
+ {
+ var properties = MakeDbCommand.CreateThroughputProperties(scale, 4000);
+
+ Assert.Equal(4000, properties.AutoscaleMaxThroughput);
+ }
+
+ [Fact]
+ public void CreateThroughputProperties_NoRu_DefaultsTo1000()
+ {
+ var properties = MakeDbCommand.CreateThroughputProperties("manual", null);
+
+ Assert.Equal(1000, properties.Throughput);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new MakeDbCommand { Name = "MyDb" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "mkdb MyDb", CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_InDatabase_ThrowsCommandException()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DatabaseState("TestDatabase", CreateTestClient());
+ var command = new MakeDbCommand { Name = "MyDb" };
+
+ var ex = await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "mkdb MyDb", CancellationToken.None));
+ Assert.Equal(MessageService.GetString("error-not_allowed_in_db"), ex.Message);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_InContainer_ThrowsCommandException()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new ContainerState("TestContainer", "TestDatabase", CreateTestClient());
+ var command = new MakeDbCommand { Name = "MyDb" };
+
+ var ex = await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "mkdb MyDb", CancellationToken.None));
+ Assert.Equal(MessageService.GetString("error-not_allowed_in_container"), ex.Message);
+ }
+
+ private static CosmosClient CreateTestClient()
+ {
+ var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/");
+ return new CosmosClient(connectionString);
+ }
+}
diff --git a/CosmosDBShell.Tests/CommandTests/MakeItemCommand.cs b/CosmosDBShell.Tests/CommandTests/MakeItemCommand.cs
index e7a7511..58c4ce9 100644
--- a/CosmosDBShell.Tests/CommandTests/MakeItemCommand.cs
+++ b/CosmosDBShell.Tests/CommandTests/MakeItemCommand.cs
@@ -3,6 +3,10 @@
// ------------------------------------------------------------
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;
namespace CosmosShell.Tests.CommandTests;
@@ -32,4 +36,78 @@ public void ParseJsonList()
Assert.Equal("Bar", dict["foo"]);
Assert.Equal(42, (int)dict["test"]);
}
+
+ [Fact]
+ public void ParseJson_NumericKinds_PromoteToNarrowestType()
+ {
+ var dict = Assert.IsAssignableFrom>(
+ MakeItemCommand.ParseJson(
+ "{ \"i\": 7, \"l\": 5000000000, \"d\": 1.5 }"));
+
+ Assert.Equal(7, Assert.IsType(dict["i"]));
+ Assert.Equal(5000000000L, Assert.IsType(dict["l"]));
+ Assert.Equal(1.5d, Assert.IsType(dict["d"]));
+ }
+
+ [Fact]
+ public void ParseJson_BooleansAndNull_MapToClrEquivalents()
+ {
+ var dict = Assert.IsAssignableFrom>(
+ MakeItemCommand.ParseJson(
+ "{ \"t\": true, \"f\": false, \"n\": null }"));
+
+ Assert.True(Assert.IsType(dict["t"]));
+ Assert.False(Assert.IsType(dict["f"]));
+ Assert.Null(dict["n"]);
+ }
+
+ [Fact]
+ public void ParseJson_NestedArraysAndObjects_AreParsedRecursively()
+ {
+ var dict = Assert.IsAssignableFrom>(
+ MakeItemCommand.ParseJson(
+ "{ \"tags\": [\"a\", \"b\"], \"meta\": { \"k\": 1 } }"));
+
+ var tags = Assert.IsAssignableFrom>(dict["tags"]);
+ Assert.Equal(["a", "b"], tags.Cast().ToArray());
+
+ var meta = Assert.IsAssignableFrom>(dict["meta"]);
+ Assert.Equal(1, Assert.IsType(meta["k"]));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NoDataAndNoPipeInput_ThrowsCommandException()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new MakeItemCommand { Data = null };
+
+ var ex = await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "mkitem", TestContext.Current.CancellationToken));
+ Assert.Equal(MessageService.GetString("error-no_input_data"), ex.Message);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_WithData_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new MakeItemCommand { Data = "{\"id\":\"1\"}" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "mkitem '{\"id\":\"1\"}'", TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_PipeInput_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new MakeItemCommand();
+ var commandState = new CommandState { Result = new ShellText("{\"id\":\"2\"}") };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, commandState, "mkitem", TestContext.Current.CancellationToken));
+ }
}
+
diff --git a/CosmosDBShell.Tests/CommandTests/RmCommandTests.cs b/CosmosDBShell.Tests/CommandTests/RmCommandTests.cs
index fd96424..974a2b3 100644
--- a/CosmosDBShell.Tests/CommandTests/RmCommandTests.cs
+++ b/CosmosDBShell.Tests/CommandTests/RmCommandTests.cs
@@ -6,9 +6,63 @@ 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.States;
+using Azure.Data.Cosmos.Shell.Util;
+using Microsoft.Azure.Cosmos;
public class RmCommandTests
{
+ private static CosmosClient CreateTestClient()
+ {
+ var connectionString = ParsedDocDBConnectionString.BuildEmulatorConnectionString("https://localhost:8081/");
+ return new CosmosClient(connectionString);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NoPatternAndNoPipeInput_ThrowsCommandException()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ var command = new RmCommand { Pattern = null };
+
+ var ex = await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "rm", TestContext.Current.CancellationToken));
+ Assert.Equal(MessageService.GetString("command-rm-error-no_filter"), ex.Message);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new RmCommand { Pattern = "test-*" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "rm test-*", TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_ConnectedWithoutDatabaseAndContainer_ThrowsNotInContainer()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new ConnectedState(CreateTestClient());
+ var command = new RmCommand { Pattern = "test-*" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "rm test-*", TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_InDatabaseWithoutContainer_ThrowsNotInContainer()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DatabaseState("TestDb", CreateTestClient());
+ var command = new RmCommand { Pattern = "test-*" };
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "rm test-*", TestContext.Current.CancellationToken));
+ }
+
[Fact]
public void TryGetPartitionKeyElements_ReturnsAllHierarchicalValues()
{
diff --git a/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs b/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs
new file mode 100644
index 0000000..1aeea43
--- /dev/null
+++ b/CosmosDBShell.Tests/CommandTests/ThemeCommandDispatchTests.cs
@@ -0,0 +1,294 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.CommandTests;
+
+using System.IO;
+using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Offline tests for 's action dispatch. These cover the
+/// read-only actions (current, list, show), the in-memory theme switching actions
+/// (use/set), and the file-backed load/save/validate/reload branches, all without
+/// launching an external editor or file browser. Tests that mutate the global
+/// save and restore it, and use unique file names so
+/// they do not collide with the real user themes directory.
+///
+[Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)]
+public class ThemeCommandDispatchTests
+{
+ private const string ValidToml =
+ """
+ name = "placeholder"
+
+ [colors]
+ literal = "purple"
+ """;
+
+ [Fact]
+ public async Task NoAction_DefaultsToCurrent()
+ {
+ var state = await RunAsync(new ThemeCommand());
+
+ Assert.True(state.IsPrinted);
+ var json = Assert.IsType(state.Result);
+ Assert.True(json.Value.TryGetProperty("active", out _));
+ }
+
+ [Fact]
+ public async Task Current_ReturnsActiveThemeName()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "current" });
+
+ var json = Assert.IsType(state.Result);
+ Assert.False(string.IsNullOrEmpty(json.Value.GetProperty("active").GetString()));
+ }
+
+ [Fact]
+ public async Task List_IncludesBuiltInThemes()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "list" });
+
+ var json = Assert.IsType(state.Result);
+ var names = json.Value.GetProperty("themes").EnumerateArray()
+ .Select(t => t.GetProperty("name").GetString())
+ .ToList();
+ Assert.Contains("default", names);
+ Assert.Contains("light", names);
+ }
+
+ [Theory]
+ [InlineData("default")]
+ [InlineData("light")]
+ [InlineData("monochrome")]
+ public async Task Show_KnownTheme_PreviewsWithoutChangingActive(string name)
+ {
+ var saved = Theme.Current;
+ try
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "show", Name = name });
+
+ var json = Assert.IsType(state.Result);
+ Assert.Equal(name, json.Value.GetProperty("previewed").GetString());
+ Assert.Same(saved, Theme.Current);
+ }
+ finally
+ {
+ Theme.Apply(saved);
+ }
+ }
+
+ [Fact]
+ public async Task Show_UnknownTheme_ReturnsError()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "show", Name = "does-not-exist-xyz" });
+
+ Assert.IsType(state);
+ }
+
+ [Fact]
+ public async Task Use_KnownTheme_AppliesIt()
+ {
+ var saved = Theme.Current;
+ try
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "use", Name = "light" });
+
+ var json = Assert.IsType(state.Result);
+ Assert.Equal("light", json.Value.GetProperty("applied").GetString());
+ }
+ finally
+ {
+ Theme.Apply(saved);
+ }
+ }
+
+ [Fact]
+ public async Task Set_AliasOfUse_AppliesTheme()
+ {
+ var saved = Theme.Current;
+ try
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "set", Name = "dark" });
+
+ var json = Assert.IsType(state.Result);
+ Assert.Equal("dark", json.Value.GetProperty("applied").GetString());
+ }
+ finally
+ {
+ Theme.Apply(saved);
+ }
+ }
+
+ [Fact]
+ public async Task Use_MissingName_ReturnsError()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "use" });
+
+ Assert.IsType(state);
+ }
+
+ [Fact]
+ public async Task Use_UnknownName_ReturnsError()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "use", Name = "nope-xyz" });
+
+ Assert.IsType(state);
+ }
+
+ [Fact]
+ public async Task Load_MissingPath_ReturnsError()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "load" });
+
+ Assert.IsType(state);
+ }
+
+ [Fact]
+ public async Task Load_ValidFile_LoadsAndApplies()
+ {
+ var name = $"load-{Guid.NewGuid():N}";
+ var path = WriteTempToml(name, ValidToml);
+ var saved = Theme.Current;
+ try
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "load", Name = path });
+
+ var json = Assert.IsType(state.Result);
+ Assert.Equal("placeholder", json.Value.GetProperty("loaded").GetString());
+ }
+ finally
+ {
+ Theme.Apply(saved);
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ 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);
+ }
+
+ [Fact]
+ 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);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public async Task Save_MissingName_ReturnsError()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "save" });
+
+ Assert.IsType(state);
+ }
+
+ [Fact]
+ public async Task Save_InvalidName_ReturnsError()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "save", Name = "../escape" });
+
+ Assert.IsType(state);
+ }
+
+ [Fact]
+ public async Task Save_ToExplicitPath_WritesFile()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"save-{Guid.NewGuid():N}.toml");
+ try
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path });
+
+ var json = Assert.IsType(state.Result);
+ Assert.Equal("exported", json.Value.GetProperty("saved").GetString());
+ Assert.True(File.Exists(path));
+ }
+ finally
+ {
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ }
+
+ [Fact]
+ public async Task Save_ExistingFileWithoutForce_ReturnsError()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"save-{Guid.NewGuid():N}.toml");
+ File.WriteAllText(path, "name = \"existing\"");
+ try
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path });
+
+ Assert.IsType(state);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public async Task Save_ExistingFileWithForce_Overwrites()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"save-{Guid.NewGuid():N}.toml");
+ File.WriteAllText(path, "name = \"existing\"");
+ try
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "save", Name = "exported", Path = path, Force = true });
+
+ var json = Assert.IsType(state.Result);
+ Assert.Equal("exported", json.Value.GetProperty("saved").GetString());
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public async Task Reload_RescansUserThemesDirectory()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "reload" });
+
+ var json = Assert.IsType(state.Result);
+ Assert.True(json.Value.TryGetProperty("reloaded", out _));
+ }
+
+ [Fact]
+ public async Task UnknownAction_ReturnsError()
+ {
+ var state = await RunAsync(new ThemeCommand { Action = "frobnicate" });
+
+ Assert.IsType(state);
+ }
+
+ private static Task RunAsync(ThemeCommand command) =>
+ command.ExecuteAsync(ShellInterpreter.Instance, new CommandState(), string.Empty, CancellationToken.None);
+
+ private static string WriteTempToml(string name, string content)
+ {
+ var path = Path.Combine(Path.GetTempPath(), name + ".toml");
+ File.WriteAllText(path, content);
+ return path;
+ }
+}
diff --git a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs
index 4341866..d890fa2 100644
--- a/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs
+++ b/CosmosDBShell.Tests/CommandTests/WatchCommandTests.cs
@@ -5,11 +5,14 @@
namespace CosmosShell.Tests.CommandTests;
using Azure.Data.Cosmos.Shell.Commands;
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.States;
///
/// Unit tests for . Covers the pure helpers that
/// parse change feed response bodies and build the change feed start position,
-/// which can be exercised without a live Cosmos DB connection.
+/// plus the option-validation and not-connected guards, all of which can be
+/// exercised without a live Cosmos DB connection.
///
public class WatchCommandTests
{
@@ -86,4 +89,33 @@ public void ResolveInterval_ValueBelowMinimum_IsClamped(double seconds)
{
Assert.Equal(TimeSpan.FromSeconds(0.1), WatchCommand.ResolveInterval(seconds));
}
+
+ [Theory]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ [InlineData(double.NegativeInfinity)]
+ public void ResolveInterval_NonFiniteValue_ThrowsCommandException(double seconds)
+ {
+ Assert.Throws(() => WatchCommand.ResolveInterval(seconds));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_NullShell_ThrowsArgumentNullException()
+ {
+ var command = new WatchCommand();
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(null!, new CommandState(), "watch", TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_Disconnected_ThrowsNotConnected()
+ {
+ using var shell = ShellInterpreter.CreateInstance();
+ shell.State = new DisconnectedState();
+ var command = new WatchCommand();
+
+ await Assert.ThrowsAsync(
+ () => command.ExecuteAsync(shell, new CommandState(), "watch", TestContext.Current.CancellationToken));
+ }
}
diff --git a/CosmosDBShell.Tests/Lsp/CosmosShellWorkspaceTests.cs b/CosmosDBShell.Tests/Lsp/CosmosShellWorkspaceTests.cs
new file mode 100644
index 0000000..819ea96
--- /dev/null
+++ b/CosmosDBShell.Tests/Lsp/CosmosShellWorkspaceTests.cs
@@ -0,0 +1,164 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Lsp;
+
+using System.Linq;
+
+using Azure.Data.Cosmos.Shell.Lsp;
+
+using OmniSharp.Extensions.LanguageServer.Protocol;
+using OmniSharp.Extensions.LanguageServer.Protocol.Models;
+
+using Xunit;
+
+public class CosmosShellWorkspaceTests
+{
+ private readonly CosmosShellWorkspace workspace = new();
+ private readonly DocumentUri uri = DocumentUri.From("file:///ws.csh");
+
+ [Fact]
+ public void OpenDocument_StoresAndParses()
+ {
+ this.workspace.OpenDocument(this.uri, "echo hello", 3);
+
+ var doc = this.workspace.GetDocument(this.uri);
+ Assert.NotNull(doc);
+ Assert.Equal("echo hello", doc!.Content);
+ Assert.Equal(3, doc.Version);
+ Assert.NotNull(doc.LastParseResult);
+ Assert.True(doc.LastParseResult!.Success);
+ }
+
+ [Fact]
+ public void GetDocument_Unknown_ReturnsNull()
+ {
+ Assert.Null(this.workspace.GetDocument(DocumentUri.From("file:///missing.csh")));
+ }
+
+ [Fact]
+ public void Documents_ReflectsOpenSet()
+ {
+ var other = DocumentUri.From("file:///other.csh");
+ this.workspace.OpenDocument(this.uri, "echo a", 1);
+ this.workspace.OpenDocument(other, "echo b", 1);
+
+ Assert.Equal(2, this.workspace.Documents.Count());
+ }
+
+ [Fact]
+ public void UpdateDocument_Existing_UpdatesContentAndVersion()
+ {
+ this.workspace.OpenDocument(this.uri, "echo a", 1);
+ this.workspace.UpdateDocument(this.uri, "echo b", 2);
+
+ var doc = this.workspace.GetDocument(this.uri);
+ Assert.Equal("echo b", doc!.Content);
+ Assert.Equal(2, doc.Version);
+ }
+
+ [Fact]
+ public void UpdateDocument_Missing_OpensDocument()
+ {
+ this.workspace.UpdateDocument(this.uri, "echo new", 5);
+
+ var doc = this.workspace.GetDocument(this.uri);
+ Assert.NotNull(doc);
+ Assert.Equal("echo new", doc!.Content);
+ Assert.Equal(5, doc.Version);
+ }
+
+ [Fact]
+ public void CloseDocument_RemovesIt()
+ {
+ this.workspace.OpenDocument(this.uri, "echo a", 1);
+ this.workspace.CloseDocument(this.uri);
+
+ Assert.Null(this.workspace.GetDocument(this.uri));
+ Assert.Empty(this.workspace.Documents);
+ }
+
+ [Fact]
+ public void GetParseResult_ReturnsDocumentResult()
+ {
+ this.workspace.OpenDocument(this.uri, "echo a", 1);
+
+ var result = this.workspace.GetParseResult(this.uri);
+ Assert.NotNull(result);
+ Assert.True(result!.Success);
+ }
+
+ [Fact]
+ public void GetParseResult_Unknown_ReturnsNull()
+ {
+ Assert.Null(this.workspace.GetParseResult(this.uri));
+ }
+
+ [Fact]
+ public void GetWordAtPosition_Unknown_ReturnsNull()
+ {
+ Assert.Null(this.workspace.GetWordAtPosition(this.uri, new Position(0, 0)));
+ }
+
+ [Fact]
+ public void GetWordAtPosition_ReturnsWord()
+ {
+ this.workspace.OpenDocument(this.uri, "echo $foo", 1);
+
+ var word = this.workspace.GetWordAtPosition(this.uri, new Position(0, 6));
+ Assert.Equal("$foo", word);
+ }
+
+ [Fact]
+ public void GetWordAtPosition_LineOutOfRange_ReturnsNull()
+ {
+ this.workspace.OpenDocument(this.uri, "echo a", 1);
+
+ Assert.Null(this.workspace.GetWordAtPosition(this.uri, new Position(10, 0)));
+ }
+
+ [Fact]
+ public void GetWordAtPosition_CharOutOfRange_ReturnsNull()
+ {
+ this.workspace.OpenDocument(this.uri, "echo", 1);
+
+ Assert.Null(this.workspace.GetWordAtPosition(this.uri, new Position(0, 20)));
+ }
+
+ [Fact]
+ public void GetWordAtPosition_AtWordEnd_ReturnsPrecedingWord()
+ {
+ this.workspace.OpenDocument(this.uri, "a b", 1);
+
+ Assert.Equal("a", this.workspace.GetWordAtPosition(this.uri, new Position(0, 1)));
+ }
+
+ [Fact]
+ public void GetCompletionContext_Unknown_ReturnsEmpty()
+ {
+ var context = this.workspace.GetCompletionContext(this.uri, new Position(0, 0));
+ Assert.Same(Azure.Data.Cosmos.Shell.Lsp.CompletionContext.Empty, context);
+ }
+
+ [Fact]
+ public void GetCompletionContext_ReturnsContextWithText()
+ {
+ this.workspace.OpenDocument(this.uri, "echo hello", 1);
+
+ var context = this.workspace.GetCompletionContext(this.uri, new Position(0, 4));
+ Assert.Equal("echo", context.TextUpToPosition);
+ Assert.NotNull(context.Document);
+ }
+
+ [Fact]
+ public void OpenDocument_WithParseError_ProducesDiagnostics()
+ {
+ // Unterminated string should yield lexer errors -> diagnostics.
+ this.workspace.OpenDocument(this.uri, "echo \"unterminated", 1);
+
+ var doc = this.workspace.GetDocument(this.uri);
+ Assert.NotNull(doc);
+ Assert.NotEmpty(doc!.Diagnostics);
+ }
+}
diff --git a/CosmosDBShell.Tests/Lsp/FoldingRangeHandlerTests.cs b/CosmosDBShell.Tests/Lsp/FoldingRangeHandlerTests.cs
new file mode 100644
index 0000000..9b5397e
--- /dev/null
+++ b/CosmosDBShell.Tests/Lsp/FoldingRangeHandlerTests.cs
@@ -0,0 +1,112 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Lsp;
+
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Lsp;
+
+using OmniSharp.Extensions.LanguageServer.Protocol;
+using OmniSharp.Extensions.LanguageServer.Protocol.Models;
+
+using Xunit;
+
+public class FoldingRangeHandlerTests
+{
+ private readonly CosmosShellWorkspace workspace = new();
+ private readonly FoldingRangeHandler handler;
+ private readonly DocumentUri uri = DocumentUri.From("file:///fold.csh");
+
+ public FoldingRangeHandlerTests()
+ {
+ this.handler = new FoldingRangeHandler(this.workspace);
+ }
+
+ private Task?> HandleAsync()
+ {
+ return this.handler.Handle(
+ new FoldingRangeRequestParam { TextDocument = new TextDocumentIdentifier { Uri = this.uri } },
+ CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Handle_NoDocument_ReturnsNull()
+ {
+ var result = await this.HandleAsync();
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task Handle_NoBraces_ReturnsEmpty()
+ {
+ this.workspace.OpenDocument(this.uri, "echo hello\necho world", 1);
+
+ var result = await this.HandleAsync();
+
+ Assert.NotNull(result);
+ Assert.Empty(result!);
+ }
+
+ [Fact]
+ public async Task Handle_SingleLineBraces_NotFolded()
+ {
+ this.workspace.OpenDocument(this.uri, "if true { echo x }", 1);
+
+ var result = await this.HandleAsync();
+
+ Assert.NotNull(result);
+ Assert.Empty(result!);
+ }
+
+ [Fact]
+ public async Task Handle_MultiLineBraces_ProducesRange()
+ {
+ this.workspace.OpenDocument(this.uri, "if true {\n echo x\n}", 1);
+
+ var result = await this.HandleAsync();
+
+ Assert.NotNull(result);
+ var range = Assert.Single(result!);
+ Assert.Equal(0, range.StartLine);
+ Assert.Equal(2, range.EndLine);
+ Assert.Equal(FoldingRangeKind.Region, range.Kind);
+ }
+
+ [Fact]
+ public async Task Handle_NestedBraces_ProducesSortedRanges()
+ {
+ this.workspace.OpenDocument(this.uri, "if a {\n if b {\n echo x\n }\n}", 1);
+
+ var result = await this.HandleAsync();
+
+ Assert.NotNull(result);
+ var ranges = result!.ToList();
+ Assert.Equal(2, ranges.Count);
+
+ // Sorted by start line ascending.
+ Assert.Equal(0, ranges[0].StartLine);
+ Assert.Equal(1, ranges[1].StartLine);
+ }
+
+ [Fact]
+ public async Task Handle_UnbalancedCloseBrace_Ignored()
+ {
+ this.workspace.OpenDocument(this.uri, "echo x\n}\n}", 1);
+
+ var result = await this.HandleAsync();
+
+ Assert.NotNull(result);
+ Assert.Empty(result!);
+ }
+
+ [Fact]
+ public void GetRegistrationOptions_TargetsCosmosShell()
+ {
+ var options = this.handler.GetRegistrationOptions();
+ Assert.NotNull(options.DocumentSelector);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/AstVisitorWalkTests.cs b/CosmosDBShell.Tests/Parser/AstVisitorWalkTests.cs
new file mode 100644
index 0000000..f8bcc29
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/AstVisitorWalkTests.cs
@@ -0,0 +1,306 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Collections.Generic;
+
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Walks parsed ASTs with a concrete subclass to exercise the
+/// default traversal behavior of the base visitor (token visits and child recursion).
+///
+public class AstVisitorWalkTests
+{
+ private sealed class RecordingVisitor : AstVisitor
+ {
+ public HashSet Visited { get; } = new();
+
+ public int TokenCount { get; private set; }
+
+ public override void VisitToken(Token token)
+ {
+ this.TokenCount++;
+ }
+
+ public override void Visit(ErrorExpression errorExpression)
+ {
+ this.Visited.Add(nameof(ErrorExpression));
+ base.Visit(errorExpression);
+ }
+
+ public override void Visit(ConstantExpression constantExpression)
+ {
+ this.Visited.Add(nameof(ConstantExpression));
+ base.Visit(constantExpression);
+ }
+
+ public override void Visit(UnaryOperatorExpression unaryOperatorExpression)
+ {
+ this.Visited.Add(nameof(UnaryOperatorExpression));
+ base.Visit(unaryOperatorExpression);
+ }
+
+ public override void Visit(BinaryOperatorExpression binaryOperatorExpression)
+ {
+ this.Visited.Add(nameof(BinaryOperatorExpression));
+ base.Visit(binaryOperatorExpression);
+ }
+
+ public override void Visit(FilterPipeExpression filterPipeExpression)
+ {
+ this.Visited.Add(nameof(FilterPipeExpression));
+ base.Visit(filterPipeExpression);
+ }
+
+ public override void Visit(ParensExpression parensExpression)
+ {
+ this.Visited.Add(nameof(ParensExpression));
+ base.Visit(parensExpression);
+ }
+
+ public override void Visit(JsonExpression jsonExpression)
+ {
+ this.Visited.Add(nameof(JsonExpression));
+ base.Visit(jsonExpression);
+ }
+
+ public override void Visit(JsonArrayExpression jSonArrayExpression)
+ {
+ this.Visited.Add(nameof(JsonArrayExpression));
+ base.Visit(jSonArrayExpression);
+ }
+
+ public override void Visit(JSonPathExpression jSonPathExpression)
+ {
+ this.Visited.Add(nameof(JSonPathExpression));
+ base.Visit(jSonPathExpression);
+ }
+
+ public override void Visit(FilterPathExpression filterPathExpression)
+ {
+ this.Visited.Add(nameof(FilterPathExpression));
+ base.Visit(filterPathExpression);
+ }
+
+ public override void Visit(FilterCallExpression filterCallExpression)
+ {
+ this.Visited.Add(nameof(FilterCallExpression));
+ base.Visit(filterCallExpression);
+ }
+
+ public override void Visit(InterpolatedStringExpression interpolatedStringExpression)
+ {
+ this.Visited.Add(nameof(InterpolatedStringExpression));
+ base.Visit(interpolatedStringExpression);
+ }
+
+ public override void Visit(VariableExpression variableExpression)
+ {
+ this.Visited.Add(nameof(VariableExpression));
+ base.Visit(variableExpression);
+ }
+
+ public override void Visit(CommandExpression commandExpression)
+ {
+ this.Visited.Add(nameof(CommandExpression));
+ base.Visit(commandExpression);
+ }
+
+ public override void Visit(CommandOption commandOption)
+ {
+ this.Visited.Add(nameof(CommandOption));
+ base.Visit(commandOption);
+ }
+
+ public override void Visit(AssignmentStatement assignmentStatement)
+ {
+ this.Visited.Add(nameof(AssignmentStatement));
+ base.Visit(assignmentStatement);
+ }
+
+ public override void Visit(BlockStatement blockStatement)
+ {
+ this.Visited.Add(nameof(BlockStatement));
+ base.Visit(blockStatement);
+ }
+
+ public override void Visit(BreakStatement breakStatement)
+ {
+ this.Visited.Add(nameof(BreakStatement));
+ base.Visit(breakStatement);
+ }
+
+ public override void Visit(CommandStatement commandStatement)
+ {
+ this.Visited.Add(nameof(CommandStatement));
+ base.Visit(commandStatement);
+ }
+
+ public override void Visit(ContinueStatement continueStatement)
+ {
+ this.Visited.Add(nameof(ContinueStatement));
+ base.Visit(continueStatement);
+ }
+
+ public override void Visit(DefStatement defStatement)
+ {
+ this.Visited.Add(nameof(DefStatement));
+ base.Visit(defStatement);
+ }
+
+ public override void Visit(DoWhileStatement doWhileStatement)
+ {
+ this.Visited.Add(nameof(DoWhileStatement));
+ base.Visit(doWhileStatement);
+ }
+
+ public override void Visit(ExecStatement execStatement)
+ {
+ this.Visited.Add(nameof(ExecStatement));
+ base.Visit(execStatement);
+ }
+
+ public override void Visit(ForStatement forStatement)
+ {
+ this.Visited.Add(nameof(ForStatement));
+ base.Visit(forStatement);
+ }
+
+ public override void Visit(IfStatement ifStatement)
+ {
+ this.Visited.Add(nameof(IfStatement));
+ base.Visit(ifStatement);
+ }
+
+ public override void Visit(LoopStatement loopStatement)
+ {
+ this.Visited.Add(nameof(LoopStatement));
+ base.Visit(loopStatement);
+ }
+
+ public override void Visit(PipeStatement pipeStatement)
+ {
+ this.Visited.Add(nameof(PipeStatement));
+ base.Visit(pipeStatement);
+ }
+
+ public override void Visit(ReturnStatement returnStatement)
+ {
+ this.Visited.Add(nameof(ReturnStatement));
+ base.Visit(returnStatement);
+ }
+
+ public override void Visit(WhileStatement whileStatement)
+ {
+ this.Visited.Add(nameof(WhileStatement));
+ base.Visit(whileStatement);
+ }
+ }
+
+ private static List Parse(string script)
+ {
+ return new StatementParser(script).ParseStatements();
+ }
+
+ private static Expression ParseFilterExpr(string input)
+ {
+ return new ExpressionParser(new Lexer(input)).ParseFilterExpression();
+ }
+
+ private static void Accept(RecordingVisitor visitor, string script)
+ {
+ foreach (var statement in Parse(script))
+ {
+ statement.Accept(visitor);
+ }
+ }
+
+ [Fact]
+ public void Walk_AllStatementTypes_AreVisited()
+ {
+ var visitor = new RecordingVisitor();
+
+ Accept(visitor, "$x = 1");
+ Accept(visitor, "{ echo hi }");
+ Accept(visitor, "break");
+ Accept(visitor, "echo hello");
+ Accept(visitor, "continue");
+ Accept(visitor, "def f() { return 1 }");
+ Accept(visitor, "do { break } while $x");
+ Accept(visitor, "exec foo");
+ Accept(visitor, "for $i in [1, 2] echo $i");
+ Accept(visitor, "if $x { echo a } else { echo b }");
+ Accept(visitor, "loop break");
+ Accept(visitor, "echo a | echo b");
+ Accept(visitor, "return 1");
+ Accept(visitor, "while $x break");
+
+ Assert.Contains(nameof(AssignmentStatement), visitor.Visited);
+ Assert.Contains(nameof(BlockStatement), visitor.Visited);
+ Assert.Contains(nameof(BreakStatement), visitor.Visited);
+ Assert.Contains(nameof(CommandStatement), visitor.Visited);
+ Assert.Contains(nameof(ContinueStatement), visitor.Visited);
+ Assert.Contains(nameof(DefStatement), visitor.Visited);
+ Assert.Contains(nameof(DoWhileStatement), visitor.Visited);
+ Assert.Contains(nameof(ExecStatement), visitor.Visited);
+ Assert.Contains(nameof(ForStatement), visitor.Visited);
+ Assert.Contains(nameof(IfStatement), visitor.Visited);
+ Assert.Contains(nameof(LoopStatement), visitor.Visited);
+ Assert.Contains(nameof(PipeStatement), visitor.Visited);
+ Assert.Contains(nameof(ReturnStatement), visitor.Visited);
+ Assert.Contains(nameof(WhileStatement), visitor.Visited);
+ Assert.True(visitor.TokenCount > 0);
+ }
+
+ [Fact]
+ public void Walk_AllExpressionTypes_AreVisited()
+ {
+ var visitor = new RecordingVisitor();
+
+ ParseFilterExpr("1 + 2").Accept(visitor);
+ ParseFilterExpr("!$x").Accept(visitor);
+ ParseFilterExpr("(1)").Accept(visitor);
+ ParseFilterExpr("[1, 2, 3]").Accept(visitor);
+ ParseFilterExpr("{ id, status }").Accept(visitor);
+ ParseFilterExpr("\"hello $x world\"").Accept(visitor);
+ ParseFilterExpr("$items[0]").Accept(visitor);
+ ParseFilterExpr(".items[0].id").Accept(visitor);
+ ParseFilterExpr("map(.id)").Accept(visitor);
+ ParseFilterExpr(".items | length").Accept(visitor);
+ ParseFilterExpr("(echo hi)").Accept(visitor);
+ ParseFilterExpr("(connect --key=abc)").Accept(visitor);
+
+ Assert.Contains(nameof(BinaryOperatorExpression), visitor.Visited);
+ Assert.Contains(nameof(ConstantExpression), visitor.Visited);
+ Assert.Contains(nameof(UnaryOperatorExpression), visitor.Visited);
+ Assert.Contains(nameof(ParensExpression), visitor.Visited);
+ Assert.Contains(nameof(JsonArrayExpression), visitor.Visited);
+ Assert.Contains(nameof(JsonExpression), visitor.Visited);
+ Assert.Contains(nameof(InterpolatedStringExpression), visitor.Visited);
+ Assert.Contains(nameof(JSonPathExpression), visitor.Visited);
+ Assert.Contains(nameof(FilterPathExpression), visitor.Visited);
+ Assert.Contains(nameof(FilterCallExpression), visitor.Visited);
+ Assert.Contains(nameof(FilterPipeExpression), visitor.Visited);
+ Assert.Contains(nameof(CommandExpression), visitor.Visited);
+ Assert.Contains(nameof(CommandOption), visitor.Visited);
+ Assert.True(visitor.TokenCount > 0);
+ }
+
+ [Fact]
+ public void Walk_DefaultVisitor_DoesNotThrow()
+ {
+ // A bare AstVisitor subclass with no overrides should traverse without error.
+ var visitor = new RecordingVisitor();
+
+ var exception = Record.Exception(() =>
+ {
+ ParseFilterExpr("1 + 2 * (3 - 4)").Accept(visitor);
+ Accept(visitor, "if $x { echo a } else { echo b }");
+ });
+
+ Assert.Null(exception);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs b/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs
new file mode 100644
index 0000000..269bc9c
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/CommandExecutionTests.cs
@@ -0,0 +1,117 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Drives execution of and :
+/// positional/variadic binding, option binding, the help short-circuit, dynamic
+/// exec dispatch, and the error branches reached through CreateCommandAsync.
+///
+public class CommandExecutionTests : TestBase
+{
+ private Statement ParseSingle(string script)
+ => new StatementParser(script).ParseStatements().Single();
+
+ private Task RunSingleAsync(string script)
+ => ParseSingle(script).RunAsync(Shell, new CommandState(), CancellationToken.None);
+
+ [Fact]
+ public async Task Command_WithVariadicArguments_BindsAllPositional()
+ {
+ var state = await RunSingleAsync("echo hello world");
+ Assert.Equal("hello world", Assert.IsType(state.Result).Text);
+ }
+
+ [Fact]
+ public async Task Command_WithHelpOption_ShowsHelp()
+ {
+ var state = await RunSingleAsync("echo --help");
+ Assert.False(state.IsError);
+ }
+
+ [Fact]
+ public async Task Command_ValuedOptions_AreBoundBeforeExecution()
+ {
+ // settings binds --db / --con then fails on the missing connection.
+ await Assert.ThrowsAsync(
+ () => RunSingleAsync("settings --db=mydb --con=mycon"));
+ }
+
+ [Fact]
+ public async Task Command_UnknownOption_Throws()
+ {
+ await Assert.ThrowsAsync(
+ () => RunSingleAsync("settings --bogus=1"));
+ }
+
+ [Fact]
+ public async Task Command_OptionMissingValue_Throws()
+ {
+ await Assert.ThrowsAsync(
+ () => RunSingleAsync("settings --db"));
+ }
+
+ [Fact]
+ public async Task Command_TooManyPositionalArguments_Throws()
+ {
+ // print declares exactly two positional parameters (id, key).
+ await Assert.ThrowsAsync(
+ () => RunSingleAsync("print a b c"));
+ }
+
+ [Fact]
+ public async Task Command_Unknown_Throws_CommandNotFoundException()
+ {
+ await Assert.ThrowsAsync(
+ () => RunSingleAsync("totallyunknowncommand999 arg"));
+ }
+
+ [Fact]
+ public async Task Exec_StringLiteralCommand_RunsIt()
+ {
+ var state = await RunSingleAsync("exec \"echo\" hi there");
+ Assert.Equal("hi there", Assert.IsType(state.Result).Text);
+ }
+
+ [Fact]
+ public async Task Exec_VariableCommand_RunsResolvedCommand()
+ {
+ SetVariable("cmd", new ShellText("echo"));
+
+ var state = await RunSingleAsync("exec $cmd hello world");
+
+ Assert.Equal("hello world", Assert.IsType(state.Result).Text);
+ }
+
+ [Fact]
+ public async Task Exec_EmptyCommandPath_Throws()
+ {
+ SetVariable("cmd", new ShellText(string.Empty));
+
+ await Assert.ThrowsAsync(
+ () => RunSingleAsync("exec $cmd"));
+ }
+
+ [Fact]
+ public async Task Exec_FailingCommandWithScriptContext_WrapsInPositionalException()
+ {
+ // When the shell carries script context, errors raised by the dynamically
+ // executed command are re-thrown as PositionalException with line/column info.
+ Shell.CurrentScriptFileName = "script.csh";
+ Shell.CurrentScriptContent = "exec $cmd";
+ SetVariable("cmd", new ShellText("totallyunknowncommand999"));
+
+ var ex = await Assert.ThrowsAsync(
+ () => RunSingleAsync("exec $cmd"));
+ Assert.Equal("script.csh", ex.FileName);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/CommandExpressionTests.cs b/CosmosDBShell.Tests/Parser/CommandExpressionTests.cs
new file mode 100644
index 0000000..731296a
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/CommandExpressionTests.cs
@@ -0,0 +1,119 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Threading;
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Exercises evaluation paths: built-in command
+/// execution, user-defined function dispatch, positional/variadic parameter binding,
+/// option binding, and the error branches (unknown command, unknown option, missing
+/// option value) reached via CreateCommandAsync.
+///
+public class CommandExpressionTests : TestBase
+{
+ private async Task EvalCommandAsync(string input)
+ {
+ var expr = new ExpressionParser(new Lexer(input)).ParseFilterExpression();
+ return await expr.EvaluateAsync(Shell, new CommandState(), CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task BuiltinCommand_InExpression_ReturnsResult()
+ {
+ var result = await EvalCommandAsync("(echo hello)");
+ var text = Assert.IsType(result);
+ Assert.Equal("hello", text.Text);
+ }
+
+ [Fact]
+ public async Task BuiltinCommand_WithMultipleArguments_BindsVariadicParameter()
+ {
+ var result = await EvalCommandAsync("(echo hello world)");
+ var text = Assert.IsType(result);
+ Assert.Equal("hello world", text.Text);
+ }
+
+ [Fact]
+ public async Task UserDefinedFunction_InExpression_IsInvoked()
+ {
+ await RunScriptAsync("def greet() { return \"hi\" }");
+
+ var result = await EvalCommandAsync("(greet)");
+
+ var text = Assert.IsType(result);
+ Assert.Equal("hi", text.Text);
+ }
+
+ [Fact]
+ public async Task UserDefinedFunction_WithArgument_ReceivesArgument()
+ {
+ await RunScriptAsync("def echoback($who) { return $who }");
+
+ var result = await EvalCommandAsync("(echoback world)");
+
+ var text = Assert.IsType(result);
+ Assert.Equal("world", text.Text);
+ }
+
+ [Fact]
+ public async Task UnknownCommand_Throws_CommandNotFoundException()
+ {
+ await Assert.ThrowsAsync(
+ () => EvalCommandAsync("(definitelynotacommand123)"));
+ }
+
+ [Fact]
+ public async Task UnknownOption_Throws_UnknownOptionException()
+ {
+ await Assert.ThrowsAsync(
+ () => EvalCommandAsync("(settings --bogus=1)"));
+ }
+
+ [Fact]
+ public async Task ValuedOption_IsBound_ThenCommandExecutes()
+ {
+ // settings binds --db / --con (exercising the valued-option branch) and then
+ // fails because the shell is not connected. Reaching NotConnectedException
+ // proves option binding completed successfully.
+ await Assert.ThrowsAsync(
+ () => EvalCommandAsync("(settings --db=mydb --con=mycon)"));
+ }
+
+ [Fact]
+ public async Task NonBooleanOption_WithoutValue_Throws_CommandException()
+ {
+ await Assert.ThrowsAsync(
+ () => EvalCommandAsync("(settings --db)"));
+ }
+
+ [Fact]
+ public async Task PositionalParameters_AreBound_ThenCommandExecutes()
+ {
+ // print binds two positional parameters (id, key) before failing on the
+ // missing connection; reaching NotConnectedException proves binding succeeded.
+ await Assert.ThrowsAsync(
+ () => EvalCommandAsync("(print a b)"));
+ }
+
+ [Fact]
+ public async Task TooManyPositionalArguments_Throws_CommandException()
+ {
+ await Assert.ThrowsAsync(
+ () => EvalCommandAsync("(print a b c)"));
+ }
+
+ [Fact]
+ public async Task VariadicParameter_AbsorbsAllPositionalArguments()
+ {
+ // echo's variadic Messages parameter consumes every positional argument.
+ var result = await EvalCommandAsync("(echo one two three)");
+ Assert.Equal("one two three", Assert.IsType(result).Text);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/ExpressionParserErrorTests.cs b/CosmosDBShell.Tests/Parser/ExpressionParserErrorTests.cs
new file mode 100644
index 0000000..e639e14
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/ExpressionParserErrorTests.cs
@@ -0,0 +1,93 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Exercises the error-reporting and recovery branches of :
+/// unexpected tokens, missing closing delimiters, unexpected end of input, and the
+/// synthetic recovery nodes. These paths report into the
+/// shared lexer error list rather than throwing.
+///
+public class ExpressionParserErrorTests
+{
+ private static (Expression? Expr, int ErrorCount) ParseFilter(string input)
+ {
+ var lexer = new Lexer(input);
+ var parser = new ExpressionParser(lexer);
+ Expression? expr = null;
+ try
+ {
+ expr = parser.ParseFilterExpression();
+ }
+ catch
+ {
+ // Some malformed inputs abort via exception; the error list is still the
+ // primary signal under test, so swallow and assert on errors below.
+ }
+
+ return (expr, lexer.Errors.Count);
+ }
+
+ [Fact]
+ public void MissingCloseParenthesis_ReportsError()
+ {
+ var (_, errors) = ParseFilter("(1 + 2");
+ Assert.True(errors > 0);
+ }
+
+ [Fact]
+ public void MissingCloseBracket_ReportsError()
+ {
+ var (_, errors) = ParseFilter("[1, 2");
+ Assert.True(errors > 0);
+ }
+
+ [Fact]
+ public void MissingCloseBrace_ReportsError()
+ {
+ var (_, errors) = ParseFilter("{ id: 1");
+ Assert.True(errors > 0);
+ }
+
+ [Fact]
+ public void UnexpectedClosingParenthesis_ReportsError()
+ {
+ var (_, errors) = ParseFilter(")");
+ Assert.True(errors > 0);
+ }
+
+ [Fact]
+ public void DanglingBinaryOperator_ReportsError()
+ {
+ var (_, errors) = ParseFilter("1 +");
+ Assert.True(errors > 0);
+ }
+
+ [Fact]
+ public void EmptyInput_ReportsErrorOrReturnsRecovery()
+ {
+ var (expr, errors) = ParseFilter(string.Empty);
+ Assert.True(errors > 0 || expr != null);
+ }
+
+ [Fact]
+ public void WellFormedExpression_ReportsNoErrors()
+ {
+ var (expr, errors) = ParseFilter("(1 + 2) * 3");
+ Assert.NotNull(expr);
+ Assert.Equal(0, errors);
+ }
+
+ [Fact]
+ public void RecoveredExpression_StillReturnsNode()
+ {
+ // Even when delimiters are missing the parser returns a (partial) node so that
+ // tolerant consumers like syntax highlighting can keep walking.
+ var (expr, _) = ParseFilter("(1 + 2");
+ Assert.NotNull(expr);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/FilterBuiltinsTests.cs b/CosmosDBShell.Tests/Parser/FilterBuiltinsTests.cs
new file mode 100644
index 0000000..cc86522
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/FilterBuiltinsTests.cs
@@ -0,0 +1,207 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Exercises the filter built-in functions (type, length, keys,
+/// contains, select, sort_by) and the supporting helpers in
+/// (DescribeKind, Contains, Compare, JsonEquals)
+/// across JSON value kinds and their error branches.
+///
+public class FilterBuiltinsTests
+{
+ private static async Task EvalAsync(string input, object? value)
+ {
+ var expr = new ExpressionParser(new Lexer(input)).ParseFilterExpression();
+ var state = new CommandState
+ {
+ Result = new ShellJson(JsonSerializer.SerializeToElement(value)),
+ };
+
+ return await expr.EvaluateAsync(ShellInterpreter.Instance, state, CancellationToken.None);
+ }
+
+ private static string TypeName(ShellObject result) => Assert.IsType(result).Text;
+
+ [Fact]
+ public async Task Type_Object_ReturnsObject()
+ => Assert.Equal("object", TypeName(await EvalAsync(". | type", new { a = 1 })));
+
+ [Fact]
+ public async Task Type_Array_ReturnsArray()
+ => Assert.Equal("array", TypeName(await EvalAsync(". | type", new[] { 1, 2 })));
+
+ [Fact]
+ public async Task Type_String_ReturnsString()
+ => Assert.Equal("string", TypeName(await EvalAsync(". | type", "hello")));
+
+ [Fact]
+ public async Task Type_Number_ReturnsNumber()
+ => Assert.Equal("number", TypeName(await EvalAsync(". | type", 42)));
+
+ [Fact]
+ public async Task Type_Boolean_ReturnsBoolean()
+ => Assert.Equal("boolean", TypeName(await EvalAsync(". | type", true)));
+
+ [Fact]
+ public async Task Type_Null_ReturnsNull()
+ => Assert.Equal("null", TypeName(await EvalAsync(". | type", (object?)null)));
+
+ [Fact]
+ public async Task Length_Object_CountsProperties()
+ {
+ var result = await EvalAsync(". | length", new { a = 1, b = 2, c = 3 });
+ Assert.Equal(3, Assert.IsType(result).Value);
+ }
+
+ [Fact]
+ public async Task Length_String_CountsCharacters()
+ {
+ var result = await EvalAsync(". | length", "abcd");
+ Assert.Equal(4, Assert.IsType(result).Value);
+ }
+
+ [Fact]
+ public async Task Length_Null_ReturnsZero()
+ {
+ var result = await EvalAsync(". | length", (object?)null);
+ Assert.Equal(0, Assert.IsType(result).Value);
+ }
+
+ [Fact]
+ public async Task Length_Number_Throws()
+ => await Assert.ThrowsAsync(() => EvalAsync(". | length", 5));
+
+ [Fact]
+ public async Task Keys_Object_ReturnsSortedKeys()
+ {
+ var result = await EvalAsync(". | keys", new { banana = 1, apple = 2, cherry = 3 });
+ var json = Assert.IsType(result);
+ var keys = json.Value.EnumerateArray().Select(e => e.GetString()).ToArray();
+ Assert.Equal(new[] { "apple", "banana", "cherry" }, keys);
+ }
+
+ [Fact]
+ public async Task Keys_Array_Throws()
+ => await Assert.ThrowsAsync(() => EvalAsync(". | keys", new[] { 1, 2 }));
+
+ [Fact]
+ public async Task Contains_NumberInArray_ReturnsTrue()
+ => Assert.True(Assert.IsType(await EvalAsync(". | contains(2)", new[] { 1, 2, 3 })).Value);
+
+ [Fact]
+ public async Task Contains_NumberNotInArray_ReturnsFalse()
+ => Assert.False(Assert.IsType(await EvalAsync(". | contains(9)", new[] { 1, 2, 3 })).Value);
+
+ [Fact]
+ public async Task Contains_Substring_ReturnsTrue()
+ => Assert.True(Assert.IsType(await EvalAsync(". | contains(\"ell\")", "hello")).Value);
+
+ [Fact]
+ public async Task Contains_ScalarEquality_ReturnsTrue()
+ => Assert.True(Assert.IsType(await EvalAsync(". | contains(5)", 5)).Value);
+
+ [Fact]
+ public async Task Select_FiltersArrayByPredicate()
+ {
+ var result = await EvalAsync(". | select(. > 2)", new[] { 1, 2, 3, 4 });
+ var json = Assert.IsType(result);
+ var values = json.Value.EnumerateArray().Select(e => e.GetInt32()).ToArray();
+ Assert.Equal(new[] { 3, 4 }, values);
+ }
+
+ [Fact]
+ public async Task Select_NonArray_Throws()
+ => await Assert.ThrowsAsync(() => EvalAsync(". | select(. > 2)", 5));
+
+ [Fact]
+ public async Task SortBy_Numbers_SortsAscending()
+ {
+ var result = await EvalAsync(". | sort_by(.)", new[] { 3, 1, 2 });
+ var json = Assert.IsType(result);
+ var values = json.Value.EnumerateArray().Select(e => e.GetInt32()).ToArray();
+ Assert.Equal(new[] { 1, 2, 3 }, values);
+ }
+
+ [Fact]
+ public async Task SortBy_Strings_SortsOrdinally()
+ {
+ var result = await EvalAsync(". | sort_by(.)", new[] { "cherry", "apple", "banana" });
+ var json = Assert.IsType(result);
+ var values = json.Value.EnumerateArray().Select(e => e.GetString()).ToArray();
+ Assert.Equal(new[] { "apple", "banana", "cherry" }, values);
+ }
+
+ [Fact]
+ public async Task SortBy_MixedKinds_DoesNotThrow()
+ {
+ // Sorting heterogeneous keys exercises the cross-kind ranking branch in Compare.
+ var result = await EvalAsync(". | sort_by(.)", new object?[] { 3, "a", true, null });
+ var json = Assert.IsType(result);
+ Assert.Equal(4, json.Value.GetArrayLength());
+ }
+
+ [Fact]
+ public async Task SortBy_NonArray_Throws()
+ => await Assert.ThrowsAsync(() => EvalAsync(". | sort_by(.)", 5));
+
+ [Fact]
+ public async Task UnknownBuiltin_Throws()
+ => await Assert.ThrowsAsync(() => EvalAsync(". | bogus_function(.)", new[] { 1 }));
+
+ [Fact]
+ public async Task Contains_ObjectSubset_ReturnsTrue()
+ {
+ // Object-in-object containment exercises the recursive ObjectEquals/Contains branch.
+ var result = await EvalAsync(
+ ". | contains({ id: 1 })",
+ new Dictionary { ["id"] = 1, ["name"] = "x" });
+ Assert.True(Assert.IsType(result).Value);
+ }
+
+ [Fact]
+ public async Task Contains_ObjectInArray_MatchesByValue()
+ {
+ // Matching an object element walks JsonEquals over objects via JsonElementComparer.
+ var items = new object[]
+ {
+ new Dictionary { ["id"] = 1 },
+ new Dictionary { ["id"] = 2 },
+ };
+ var result = await EvalAsync(". | contains({ id: 2 })", items);
+ Assert.True(Assert.IsType(result).Value);
+ }
+
+ [Fact]
+ public async Task SortBy_ArrayKeys_OrdersByRawText()
+ {
+ // Array keys fall through Compare's raw-text branch without throwing.
+ var rows = new object[]
+ {
+ new Dictionary { ["k"] = new[] { 2 } },
+ new Dictionary { ["k"] = new[] { 1 } },
+ };
+ var result = await EvalAsync(". | sort_by(.k)", rows);
+ Assert.Equal(2, Assert.IsType(result).Value.GetArrayLength());
+ }
+
+ [Fact]
+ public async Task ArrayEquality_ViaContains_UsesSequenceComparer()
+ {
+ // Containment of an array element compares arrays element-by-element.
+ var items = new object[] { new[] { 1, 2 }, new[] { 3, 4 } };
+ var result = await EvalAsync(". | contains([3, 4])", items);
+ Assert.True(Assert.IsType(result).Value);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/FilterPathExpressionTests.cs b/CosmosDBShell.Tests/Parser/FilterPathExpressionTests.cs
new file mode 100644
index 0000000..d072424
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/FilterPathExpressionTests.cs
@@ -0,0 +1,103 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Exercises navigation: property access, indexing,
+/// iteration ([]), optional segments (?), missing-property null fallback,
+/// out-of-range indexing, and the type-mismatch error branches for each segment kind.
+///
+public class FilterPathExpressionTests
+{
+ private static async Task EvalAsync(string input, object? value)
+ {
+ var expr = new ExpressionParser(new Lexer(input)).ParseFilterExpression();
+ var state = new CommandState
+ {
+ Result = new ShellJson(JsonSerializer.SerializeToElement(value)),
+ };
+
+ return await expr.EvaluateAsync(ShellInterpreter.Instance, state, CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Property_ExistingKey_ReturnsValue()
+ {
+ var result = await EvalAsync(".name", new { name = "abu" });
+ Assert.Equal("abu", Assert.IsType(result).Value.GetString());
+ }
+
+ [Fact]
+ public async Task Property_MissingKey_ReturnsNull()
+ {
+ var result = await EvalAsync(".missing", new { name = "abu" });
+ Assert.Equal(JsonValueKind.Null, Assert.IsType(result).Value.ValueKind);
+ }
+
+ [Fact]
+ public async Task Property_OnNonObjectWithoutOptional_Throws()
+ => await Assert.ThrowsAsync(() => EvalAsync(".name", 5));
+
+ [Fact]
+ public async Task Property_OnNonObjectWithOptional_ReturnsNull()
+ {
+ var result = await EvalAsync(".name?", 5);
+ Assert.Equal(JsonValueKind.Null, Assert.IsType(result).Value.ValueKind);
+ }
+
+ [Fact]
+ public async Task Index_InRange_ReturnsElement()
+ {
+ var result = await EvalAsync(".[1]", new[] { 10, 20, 30 });
+ Assert.Equal(20, Assert.IsType(result).Value.GetInt32());
+ }
+
+ [Fact]
+ public async Task Index_OutOfRange_ReturnsNull()
+ {
+ var result = await EvalAsync(".[9]", new[] { 10, 20, 30 });
+ Assert.Equal(JsonValueKind.Null, Assert.IsType(result).Value.ValueKind);
+ }
+
+ [Fact]
+ public async Task Index_OnNonArrayWithoutOptional_Throws()
+ => await Assert.ThrowsAsync(() => EvalAsync(".[0]", new { a = 1 }));
+
+ [Fact]
+ public async Task Iterate_OverArray_ReturnsSequence()
+ {
+ var result = await EvalAsync(".items[]", new { items = new[] { 1, 2, 3 } });
+ var sequence = Assert.IsType(result);
+ Assert.Equal(3, sequence.Elements.Count);
+ }
+
+ [Fact]
+ public async Task Iterate_OnNonArrayWithoutOptional_Throws()
+ => await Assert.ThrowsAsync(() => EvalAsync(".[]", new { a = 1 }));
+
+ [Fact]
+ public async Task NestedPath_PropertyThenIndex_ReturnsElement()
+ {
+ var result = await EvalAsync(".items[0].id", new { items = new[] { new { id = "x" } } });
+ Assert.Equal("x", Assert.IsType(result).Value.GetString());
+ }
+
+ [Fact]
+ public async Task Property_OnStringScalarWithOptional_ReturnsNull()
+ {
+ // A string scalar is not an object; the optional marker suppresses the type error.
+ var result = await EvalAsync(".name?", "scalar");
+ Assert.Equal(JsonValueKind.Null, Assert.IsType(result).Value.ValueKind);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/JsonConstructionTests.cs b/CosmosDBShell.Tests/Parser/JsonConstructionTests.cs
new file mode 100644
index 0000000..c69c2e1
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/JsonConstructionTests.cs
@@ -0,0 +1,128 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Exercises JSON object construction () and array
+/// construction () across every value DataType branch
+/// (Json, Number, Decimal, Boolean, Text, null) plus filter-sequence flattening and
+/// shorthand property capture from the current result.
+///
+public class JsonConstructionTests
+{
+ private static async Task EvalJsonAsync(string input, object? value = null)
+ {
+ var expr = new ExpressionParser(new Lexer(input)).ParseFilterExpression();
+ var state = new CommandState();
+ if (value != null)
+ {
+ state.Result = new ShellJson(JsonSerializer.SerializeToElement(value));
+ }
+
+ var result = await expr.EvaluateAsync(ShellInterpreter.Instance, state, CancellationToken.None);
+ return Assert.IsType(result).Value;
+ }
+
+ [Fact]
+ public async Task Array_WithNumbers_BuildsJsonArray()
+ {
+ var json = await EvalJsonAsync("[1, 2, 3]");
+ Assert.Equal(JsonValueKind.Array, json.ValueKind);
+ Assert.Equal(new[] { 1, 2, 3 }, json.EnumerateArray().Select(e => e.GetInt32()));
+ }
+
+ [Fact]
+ public async Task Array_WithDecimals_BuildsJsonArray()
+ {
+ var json = await EvalJsonAsync("[1.5, 2.5]");
+ Assert.Equal(new[] { 1.5, 2.5 }, json.EnumerateArray().Select(e => e.GetDouble()));
+ }
+
+ [Fact]
+ public async Task Array_WithStrings_BuildsJsonArray()
+ {
+ var json = await EvalJsonAsync("[\"a\", \"b\"]");
+ Assert.Equal(new[] { "a", "b" }, json.EnumerateArray().Select(e => e.GetString()));
+ }
+
+ [Fact]
+ public async Task Array_WithBooleans_BuildsJsonArray()
+ {
+ var json = await EvalJsonAsync("[true, false]");
+ Assert.Equal(new[] { JsonValueKind.True, JsonValueKind.False }, json.EnumerateArray().Select(e => e.ValueKind));
+ }
+
+ [Fact]
+ public async Task Array_WithNestedArray_BuildsNestedJson()
+ {
+ var json = await EvalJsonAsync("[[1, 2], [3]]");
+ Assert.Equal(2, json.GetArrayLength());
+ Assert.Equal(JsonValueKind.Array, json[0].ValueKind);
+ }
+
+ [Fact]
+ public async Task Array_Empty_BuildsEmptyJsonArray()
+ {
+ var json = await EvalJsonAsync("[]");
+ Assert.Equal(JsonValueKind.Array, json.ValueKind);
+ Assert.Equal(0, json.GetArrayLength());
+ }
+
+ [Fact]
+ public async Task Array_FlattensFilterSequence()
+ {
+ var json = await EvalJsonAsync("[.items[]]", new { items = new[] { 10, 20, 30 } });
+ Assert.Equal(new[] { 10, 20, 30 }, json.EnumerateArray().Select(e => e.GetInt32()));
+ }
+
+ [Fact]
+ public async Task Object_WithMixedValueTypes_BuildsJsonObject()
+ {
+ var json = await EvalJsonAsync("{ n: 1, d: 2.5, s: \"hi\", b: true }");
+ Assert.Equal(JsonValueKind.Object, json.ValueKind);
+ Assert.Equal(1, json.GetProperty("n").GetInt32());
+ Assert.Equal(2.5, json.GetProperty("d").GetDouble());
+ Assert.Equal("hi", json.GetProperty("s").GetString());
+ Assert.Equal(JsonValueKind.True, json.GetProperty("b").ValueKind);
+ }
+
+ [Fact]
+ public async Task Object_WithNullValue_SerializesNull()
+ {
+ var json = await EvalJsonAsync("{ x: null }");
+ Assert.Equal(JsonValueKind.Null, json.GetProperty("x").ValueKind);
+ }
+
+ [Fact]
+ public async Task Object_WithNestedObject_BuildsNestedJson()
+ {
+ var json = await EvalJsonAsync("{ outer: { inner: 1 } }");
+ Assert.Equal(1, json.GetProperty("outer").GetProperty("inner").GetInt32());
+ }
+
+ [Fact]
+ public async Task Object_ShorthandProperties_CaptureFromCurrentResult()
+ {
+ var json = await EvalJsonAsync("{ id, status }", new { id = "1", status = "active", extra = "ignored" });
+ Assert.Equal("1", json.GetProperty("id").GetString());
+ Assert.Equal("active", json.GetProperty("status").GetString());
+ Assert.False(json.TryGetProperty("extra", out _));
+ }
+
+ [Fact]
+ public async Task Object_PropertyFromJsonPath_BuildsJsonObject()
+ {
+ var json = await EvalJsonAsync("{ name: .title }", new { title = "Volcano" });
+ Assert.Equal("Volcano", json.GetProperty("name").GetString());
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/OperatorEvaluationTests.cs b/CosmosDBShell.Tests/Parser/OperatorEvaluationTests.cs
new file mode 100644
index 0000000..b5242f6
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/OperatorEvaluationTests.cs
@@ -0,0 +1,185 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Evaluates binary and unary operator expressions to exercise the arithmetic,
+/// comparison, logical, and type-coercion branches in
+/// and .
+///
+public class OperatorEvaluationTests
+{
+ private static async Task EvalAsync(string input)
+ {
+ var expression = new ExpressionParser(new Lexer(input)).ParseFilterExpression();
+ return await expression.EvaluateAsync(ShellInterpreter.Instance, new CommandState(), CancellationToken.None);
+ }
+
+ [Theory]
+ [InlineData("1 + 2", 3)]
+ [InlineData("5 - 3", 2)]
+ [InlineData("4 * 3", 12)]
+ [InlineData("10 / 2", 5)]
+ [InlineData("10 % 3", 1)]
+ [InlineData("2 ** 3", 8)]
+ public async Task IntegerArithmetic_ReturnsExpectedNumber(string input, int expected)
+ {
+ var result = await EvalAsync(input);
+ var number = Assert.IsType(result);
+ Assert.Equal(expected, number.Value);
+ }
+
+ [Theory]
+ [InlineData("1.5 + 2.5", 4.0)]
+ [InlineData("5.0 - 2.5", 2.5)]
+ [InlineData("2.5 * 2.0", 5.0)]
+ [InlineData("5.0 / 2.0", 2.5)]
+ [InlineData("5.5 % 2.0", 1.5)]
+ [InlineData("2.0 ** 3.0", 8.0)]
+ public async Task DecimalArithmetic_ReturnsExpectedDecimal(string input, double expected)
+ {
+ var result = await EvalAsync(input);
+ var dec = Assert.IsType(result);
+ Assert.Equal(expected, dec.Value);
+ }
+
+ [Theory]
+ [InlineData("\"a\" + \"b\"", "ab")]
+ [InlineData("\"n=\" + 5", "n=5")]
+ [InlineData("5 + \"px\"", "5px")]
+ public async Task StringConcatenation_ReturnsText(string input, string expected)
+ {
+ var result = await EvalAsync(input);
+ var text = Assert.IsType(result);
+ Assert.Equal(expected, text.Text);
+ }
+
+ [Fact]
+ public async Task ArrayConcatenation_MergesJsonArrays()
+ {
+ var result = await EvalAsync("[1, 2] + [3]");
+ var json = Assert.IsType(result);
+ Assert.Equal(JsonValueKind.Array, json.Value.ValueKind);
+ Assert.Equal(3, json.Value.GetArrayLength());
+ }
+
+ [Theory]
+ [InlineData("1 == 1", true)]
+ [InlineData("1 == 2", false)]
+ [InlineData("1 != 2", true)]
+ [InlineData("1 != 1", false)]
+ [InlineData("1 < 2", true)]
+ [InlineData("2 < 1", false)]
+ [InlineData("2 > 1", true)]
+ [InlineData("1 <= 1", true)]
+ [InlineData("2 <= 1", false)]
+ [InlineData("2 >= 2", true)]
+ [InlineData("2 >= 3", false)]
+ public async Task IntegerComparison_ReturnsExpectedBool(string input, bool expected)
+ {
+ var result = await EvalAsync(input);
+ var boolean = Assert.IsType(result);
+ Assert.Equal(expected, boolean.Value);
+ }
+
+ [Theory]
+ [InlineData("1.5 == 1.5", true)]
+ [InlineData("1.5 < 2.5", true)]
+ [InlineData("2.5 > 1.5", true)]
+ [InlineData("2.5 <= 2.5", true)]
+ [InlineData("2.5 >= 3.5", false)]
+ public async Task DecimalComparison_ReturnsExpectedBool(string input, bool expected)
+ {
+ var result = await EvalAsync(input);
+ var boolean = Assert.IsType(result);
+ Assert.Equal(expected, boolean.Value);
+ }
+
+ [Theory]
+ [InlineData("true == true", true)]
+ [InlineData("true == false", false)]
+ [InlineData("\"a\" == \"a\"", true)]
+ [InlineData("\"a\" == \"b\"", false)]
+ [InlineData("1 == \"1\"", true)]
+ public async Task EqualityAcrossTypes_ReturnsExpectedBool(string input, bool expected)
+ {
+ var result = await EvalAsync(input);
+ var boolean = Assert.IsType(result);
+ Assert.Equal(expected, boolean.Value);
+ }
+
+ [Theory]
+ [InlineData("true && true", true)]
+ [InlineData("true && false", false)]
+ [InlineData("false || true", true)]
+ [InlineData("false || false", false)]
+ [InlineData("true ^ false", true)]
+ [InlineData("true ^ true", false)]
+ public async Task LogicalOperators_ReturnExpectedBool(string input, bool expected)
+ {
+ var result = await EvalAsync(input);
+ var boolean = Assert.IsType(result);
+ Assert.Equal(expected, boolean.Value);
+ }
+
+ [Fact]
+ public async Task LogicalAnd_ShortCircuits_DoesNotEvaluateRight()
+ {
+ // The right side would divide by zero; short-circuit must avoid evaluating it.
+ var result = await EvalAsync("false && (10 / 0 == 0)");
+ Assert.False(Assert.IsType(result).Value);
+ }
+
+ [Fact]
+ public async Task LogicalOr_ShortCircuits_DoesNotEvaluateRight()
+ {
+ var result = await EvalAsync("true || (10 / 0 == 0)");
+ Assert.True(Assert.IsType(result).Value);
+ }
+
+ [Theory]
+ [InlineData("10 / 0")]
+ [InlineData("10.0 / 0.0")]
+ [InlineData("10 % 0")]
+ [InlineData("10.0 % 0.0")]
+ public async Task DivisionOrModuloByZero_Throws(string input)
+ {
+ await Assert.ThrowsAsync(() => EvalAsync(input));
+ }
+
+ [Theory]
+ [InlineData("!true", false)]
+ [InlineData("!false", true)]
+ public async Task UnaryNot_NegatesBoolean(string input, bool expected)
+ {
+ var result = await EvalAsync(input);
+ Assert.Equal(expected, Assert.IsType(result).Value);
+ }
+
+ [Theory]
+ [InlineData("-5", -5)]
+ [InlineData("+5", 5)]
+ public async Task UnaryNumeric_ReturnsNumber(string input, int expected)
+ {
+ var result = await EvalAsync(input);
+ Assert.Equal(expected, Assert.IsType(result).Value);
+ }
+
+ [Theory]
+ [InlineData("-2.5", -2.5)]
+ [InlineData("+2.5", 2.5)]
+ public async Task UnaryNumeric_ReturnsDecimal(string input, double expected)
+ {
+ var result = await EvalAsync(input);
+ Assert.Equal(expected, Assert.IsType(result).Value);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/PositionalErrorTests.cs b/CosmosDBShell.Tests/Parser/PositionalErrorTests.cs
new file mode 100644
index 0000000..97f9aee
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/PositionalErrorTests.cs
@@ -0,0 +1,93 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System;
+
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Direct unit tests for (line/column
+/// resolution from a flat character offset) and the
+/// carrier used to surface script-relative error positions.
+///
+public class PositionalErrorTests
+{
+ [Fact]
+ public void GetLineAndColumn_FirstCharacter_IsLineOneColumnOne()
+ {
+ var (line, column, lineText) = PositionalErrorHelper.GetLineAndColumn("hello world", 0);
+ Assert.Equal(1, line);
+ Assert.Equal(1, column);
+ Assert.Equal("hello world", lineText);
+ }
+
+ [Fact]
+ public void GetLineAndColumn_MidFirstLine_TracksColumn()
+ {
+ var (line, column, _) = PositionalErrorHelper.GetLineAndColumn("hello world", 6);
+ Assert.Equal(1, line);
+ Assert.Equal(7, column);
+ }
+
+ [Fact]
+ public void GetLineAndColumn_SecondLine_TracksLineAndColumn()
+ {
+ var text = "first\nsecond\nthird";
+ var position = text.IndexOf("second", StringComparison.Ordinal) + 2;
+ var (line, column, lineText) = PositionalErrorHelper.GetLineAndColumn(text, position);
+ Assert.Equal(2, line);
+ Assert.Equal(3, column);
+ Assert.Equal("second", lineText);
+ }
+
+ [Fact]
+ public void GetLineAndColumn_LastLineWithoutTrailingNewline_ReturnsRemainder()
+ {
+ var text = "a\nb\nlast line";
+ var (line, _, lineText) = PositionalErrorHelper.GetLineAndColumn(text, text.Length);
+ Assert.Equal(3, line);
+ Assert.Equal("last line", lineText);
+ }
+
+ [Fact]
+ public void GetLineAndColumn_TrimsCarriageReturn()
+ {
+ var text = "windows\r\nline";
+ var (line, _, lineText) = PositionalErrorHelper.GetLineAndColumn(text, 0);
+ Assert.Equal(1, line);
+ Assert.Equal("windows", lineText);
+ }
+
+ [Fact]
+ public void GetLineAndColumn_PositionBeyondLength_ClampsToEnd()
+ {
+ var text = "short";
+ var (line, _, lineText) = PositionalErrorHelper.GetLineAndColumn(text, 1000);
+ Assert.Equal(1, line);
+ Assert.Equal("short", lineText);
+ }
+
+ [Fact]
+ public void PositionalException_ExposesPositionAndInnerMessage()
+ {
+ var inner = new InvalidOperationException("boom");
+ var ex = new PositionalException("script.csh", inner, line: 4, column: 7, lineText: "echo bad");
+
+ Assert.Equal("script.csh", ex.FileName);
+ Assert.Equal(4, ex.Line);
+ Assert.Equal(7, ex.Column);
+ Assert.Equal("echo bad", ex.LineText);
+ Assert.Equal("boom", ex.Message);
+ Assert.Same(inner, ex.InnerException);
+ }
+
+ [Fact]
+ public void PositionalException_AllowsNullLineText()
+ {
+ var ex = new PositionalException("f.csh", new Exception("x"), 1, 1);
+ Assert.Null(ex.LineText);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/ShellObjectConversionTests.cs b/CosmosDBShell.Tests/Parser/ShellObjectConversionTests.cs
new file mode 100644
index 0000000..18ed743
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/ShellObjectConversionTests.cs
@@ -0,0 +1,205 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Text.Json;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Conversion tests for the value types
+/// (, , ).
+/// These are pure functions that map a value to each , including
+/// the error branches for unconvertible values.
+///
+public class ShellObjectConversionTests
+{
+ private static ShellJson Json(string raw)
+ {
+ using var doc = JsonDocument.Parse(raw);
+ return new ShellJson(doc.RootElement.Clone());
+ }
+
+ [Fact]
+ public void ShellJson_NumberToAllTypes()
+ {
+ var json = Json("42");
+ Assert.Equal("42", json.ConvertShellObject(DataType.Text));
+ Assert.Equal(42, json.ConvertShellObject(DataType.Number));
+ Assert.Equal(42m, json.ConvertShellObject(DataType.Decimal));
+ Assert.Equal(true, json.ConvertShellObject(DataType.Boolean));
+ }
+
+ [Fact]
+ public void ShellJson_ZeroNumber_IsFalse()
+ {
+ Assert.Equal(false, Json("0").ConvertShellObject(DataType.Boolean));
+ }
+
+ [Fact]
+ public void ShellJson_StringValue_TextReturnsUnquoted()
+ {
+ var json = Json("\"hello\"");
+ Assert.Equal("hello", json.ConvertShellObject(DataType.Text));
+ }
+
+ [Theory]
+ [InlineData("\"true\"", true)]
+ [InlineData("\"1\"", true)]
+ [InlineData("\"yes\"", true)]
+ [InlineData("\"false\"", false)]
+ [InlineData("\"0\"", false)]
+ [InlineData("\"no\"", false)]
+ [InlineData("\"\"", false)]
+ public void ShellJson_StringToBoolean(string raw, bool expected)
+ {
+ Assert.Equal(expected, Json(raw).ConvertShellObject(DataType.Boolean));
+ }
+
+ [Theory]
+ [InlineData("\"123\"", 123)]
+ public void ShellJson_StringToNumber(string raw, int expected)
+ {
+ Assert.Equal(expected, Json(raw).ConvertShellObject(DataType.Number));
+ }
+
+ [Fact]
+ public void ShellJson_StringToDecimal()
+ {
+ Assert.Equal(15m, Json("\"15\"").ConvertShellObject(DataType.Decimal));
+ }
+
+ [Fact]
+ public void ShellJson_TrueFalseLiterals_ToBoolean()
+ {
+ Assert.Equal(true, Json("true").ConvertShellObject(DataType.Boolean));
+ Assert.Equal(false, Json("false").ConvertShellObject(DataType.Boolean));
+ }
+
+ [Fact]
+ public void ShellJson_ObjectToText_ReturnsRawJson()
+ {
+ var json = Json("{\"id\":\"1\"}");
+ var text = Assert.IsType(json.ConvertShellObject(DataType.Text));
+ Assert.Contains("\"id\"", text);
+ }
+
+ [Fact]
+ public void ShellJson_JsonToJson_ReturnsElement()
+ {
+ var json = Json("[1,2,3]");
+ var element = Assert.IsType(json.ConvertShellObject(DataType.Json));
+ Assert.Equal(JsonValueKind.Array, element.ValueKind);
+ }
+
+ [Fact]
+ public void ShellJson_NonNumericStringToNumber_Throws()
+ {
+ Assert.Throws(() => Json("\"abc\"").ConvertShellObject(DataType.Number));
+ }
+
+ [Fact]
+ public void ShellJson_ObjectToBoolean_Throws()
+ {
+ Assert.Throws(() => Json("{}").ConvertShellObject(DataType.Boolean));
+ }
+
+ [Fact]
+ public void ShellJson_ObjectToDecimal_Throws()
+ {
+ Assert.Throws(() => Json("{}").ConvertShellObject(DataType.Decimal));
+ }
+
+ [Fact]
+ public void ShellIdentifier_Text_ReturnsValue()
+ {
+ Assert.Equal("abc", new ShellIdentifier("abc").ConvertShellObject(DataType.Text));
+ }
+
+ [Theory]
+ [InlineData("true", true)]
+ [InlineData("1", true)]
+ [InlineData("yes", true)]
+ [InlineData("false", false)]
+ [InlineData("0", false)]
+ [InlineData("no", false)]
+ [InlineData("", false)]
+ public void ShellIdentifier_ToBoolean(string value, bool expected)
+ {
+ Assert.Equal(expected, new ShellIdentifier(value).ConvertShellObject(DataType.Boolean));
+ }
+
+ [Fact]
+ public void ShellIdentifier_ToNumber()
+ {
+ Assert.Equal(7, new ShellIdentifier("7").ConvertShellObject(DataType.Number));
+ }
+
+ [Fact]
+ public void ShellIdentifier_ToDecimal()
+ {
+ Assert.Equal(25d, new ShellIdentifier("25").ConvertShellObject(DataType.Decimal));
+ }
+
+ [Fact]
+ public void ShellIdentifier_ToJson_ParsesValue()
+ {
+ var element = Assert.IsType(new ShellIdentifier("{\"a\":1}").ConvertShellObject(DataType.Json));
+ Assert.Equal(1, element.GetProperty("a").GetInt32());
+ }
+
+ [Fact]
+ public void ShellIdentifier_InvalidBoolean_Throws()
+ {
+ Assert.Throws(() => new ShellIdentifier("maybe").ConvertShellObject(DataType.Boolean));
+ }
+
+ [Fact]
+ public void ShellIdentifier_InvalidNumber_Throws()
+ {
+ Assert.Throws(() => new ShellIdentifier("abc").ConvertShellObject(DataType.Number));
+ }
+
+ [Fact]
+ public void ShellIdentifier_InvalidDecimal_Throws()
+ {
+ Assert.Throws(() => new ShellIdentifier("abc").ConvertShellObject(DataType.Decimal));
+ }
+
+ [Fact]
+ public void ShellIdentifier_InvalidJson_Throws()
+ {
+ Assert.Throws(() => new ShellIdentifier("{not json").ConvertShellObject(DataType.Json));
+ }
+
+ [Fact]
+ public void ShellSequence_ToJson_ReturnsArray()
+ {
+ var seq = SequenceOf("1", "2", "3");
+ var element = Assert.IsType(seq.ConvertShellObject(DataType.Json));
+ Assert.Equal(JsonValueKind.Array, element.ValueKind);
+ Assert.Equal(3, element.GetArrayLength());
+ }
+
+ [Fact]
+ public void ShellSequence_ToText_ReturnsRawArrayJson()
+ {
+ var seq = SequenceOf("1", "2");
+ var text = Assert.IsType(seq.ConvertShellObject(DataType.Text));
+ Assert.StartsWith("[", text);
+ Assert.EndsWith("]", text);
+ }
+
+ private static ShellSequence SequenceOf(params string[] rawElements)
+ {
+ var elements = new List();
+ foreach (var raw in rawElements)
+ {
+ using var doc = JsonDocument.Parse(raw);
+ elements.Add(doc.RootElement.Clone());
+ }
+
+ return new ShellSequence(elements);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/ShellObjectParseTests.cs b/CosmosDBShell.Tests/Parser/ShellObjectParseTests.cs
new file mode 100644
index 0000000..c2a7b73
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/ShellObjectParseTests.cs
@@ -0,0 +1,66 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Exercises , which maps the first lexer token
+/// to a concrete subtype (boolean, number, identifier, text).
+///
+public class ShellObjectParseTests
+{
+ private static ShellObject Parse(string input) => ShellObject.Parse(new Lexer(input));
+
+ [Theory]
+ [InlineData("true")]
+ [InlineData("TRUE")]
+ [InlineData("True")]
+ public void Parse_TrueLiteral_ReturnsBoolTrue(string input)
+ {
+ var result = Parse(input);
+ Assert.True(Assert.IsType(result).Value);
+ }
+
+ [Theory]
+ [InlineData("false")]
+ [InlineData("FALSE")]
+ [InlineData("False")]
+ public void Parse_FalseLiteral_ReturnsBoolFalse(string input)
+ {
+ var result = Parse(input);
+ Assert.False(Assert.IsType(result).Value);
+ }
+
+ [Theory]
+ [InlineData("0", 0)]
+ [InlineData("42", 42)]
+ public void Parse_NumericToken_ReturnsNumber(string input, int expected)
+ {
+ var result = Parse(input);
+ Assert.Equal(expected, Assert.IsType(result).Value);
+ }
+
+ [Fact]
+ public void Parse_DecimalToken_ReturnsDecimal()
+ {
+ var result = Parse("1.5");
+ Assert.Equal(1.5, Assert.IsType(result).Value);
+ }
+
+ [Fact]
+ public void Parse_BareWord_ReturnsIdentifier()
+ {
+ var result = Parse("hello");
+ Assert.Equal("hello", Assert.IsType(result).Value);
+ }
+
+ [Fact]
+ public void Parse_QuotedString_ReturnsText()
+ {
+ var result = Parse("\"quoted value\"");
+ Assert.Equal("quoted value", Assert.IsType(result).Text);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/StatementExecutionTests.cs b/CosmosDBShell.Tests/Parser/StatementExecutionTests.cs
new file mode 100644
index 0000000..3f01962
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/StatementExecutionTests.cs
@@ -0,0 +1,187 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Executes whole scripts to drive the RunAsync branches of control-flow
+/// statements (if/else, while, do-while, for, loop, break, continue, return, blocks)
+/// together with the condition-evaluation paths in the expression parser.
+///
+public class StatementExecutionTests : TestBase
+{
+ private int GetInt(string name)
+ {
+ var value = GetVariable(name);
+ return (int)Assert.IsType(value).Value;
+ }
+
+ [Fact]
+ public async Task If_TrueCondition_ExecutesThenBranch()
+ {
+ var state = await RunScriptAsync("$x = 0\nif 1 < 2 { $x = 10 } else { $x = 20 }");
+ Assert.False(state.IsError);
+ Assert.Equal(10, GetInt("x"));
+ }
+
+ [Fact]
+ public async Task If_FalseCondition_ExecutesElseBranch()
+ {
+ var state = await RunScriptAsync("$x = 0\nif 1 > 2 { $x = 10 } else { $x = 20 }");
+ Assert.False(state.IsError);
+ Assert.Equal(20, GetInt("x"));
+ }
+
+ [Fact]
+ public async Task If_FalseCondition_NoElse_DoesNothing()
+ {
+ var state = await RunScriptAsync("$x = 5\nif false { $x = 99 }");
+ Assert.False(state.IsError);
+ Assert.Equal(5, GetInt("x"));
+ }
+
+ [Fact]
+ public async Task While_Counts_UntilConditionFalse()
+ {
+ var state = await RunScriptAsync("$i = 0\nwhile $i < 3 { $i = ($i + 1) }");
+ Assert.False(state.IsError);
+ Assert.Equal(3, GetInt("i"));
+ }
+
+ [Fact]
+ public async Task While_Break_ExitsEarly()
+ {
+ var state = await RunScriptAsync("$i = 0\nwhile $i < 100 { $i = ($i + 1)\nif $i == 5 break }");
+ Assert.False(state.IsError);
+ Assert.Equal(5, GetInt("i"));
+ }
+
+ [Fact]
+ public async Task While_Continue_SkipsRemainderOfBody()
+ {
+ var script = "$i = 0\n$count = 0\nwhile $i < 5 { $i = ($i + 1)\nif $i == 3 continue\n$count = ($count + 1) }";
+ var state = await RunScriptAsync(script);
+ Assert.False(state.IsError);
+ Assert.Equal(5, GetInt("i"));
+ Assert.Equal(4, GetInt("count"));
+ }
+
+ [Fact]
+ public async Task DoWhile_ExecutesBodyAtLeastOnce_WhenConditionFalse()
+ {
+ var state = await RunScriptAsync("$x = 0\ndo { $x = ($x + 1) } while $x < 0");
+ Assert.False(state.IsError);
+ Assert.Equal(1, GetInt("x"));
+ }
+
+ [Fact]
+ public async Task For_OverArray_AccumulatesValues()
+ {
+ var state = await RunScriptAsync("$sum = 0\nfor $i in [1, 2, 3, 4] { $sum = ($sum + $i) }");
+ Assert.False(state.IsError);
+ Assert.Equal(10, GetInt("sum"));
+ }
+
+ [Fact]
+ public async Task For_Break_StopsIteration()
+ {
+ var state = await RunScriptAsync("$sum = 0\nfor $i in [1, 2, 3, 4] { if $i == 3 break\n$sum = ($sum + $i) }");
+ Assert.False(state.IsError);
+ Assert.Equal(3, GetInt("sum"));
+ }
+
+ [Fact]
+ public async Task For_Continue_SkipsSelectedIterations()
+ {
+ var state = await RunScriptAsync("$sum = 0\nfor $i in [1, 2, 3, 4] { if $i == 2 continue\n$sum = ($sum + $i) }");
+ Assert.False(state.IsError);
+ Assert.Equal(8, GetInt("sum"));
+ }
+
+ [Fact]
+ public async Task Loop_Break_TerminatesWithCounter()
+ {
+ var state = await RunScriptAsync("$i = 0\nloop { $i = ($i + 1)\nif $i == 7 break }");
+ Assert.False(state.IsError);
+ Assert.Equal(7, GetInt("i"));
+ }
+
+ [Fact]
+ public async Task NestedControlFlow_ComputesExpected()
+ {
+ var script = "$total = 0\nfor $i in [1, 2, 3] { $j = 0\nwhile $j < $i { $total = ($total + 1)\n$j = ($j + 1) } }";
+ var state = await RunScriptAsync(script);
+ Assert.False(state.IsError);
+ Assert.Equal(6, GetInt("total"));
+ }
+
+ [Fact]
+ public async Task Block_ExecutesStatementsSequentially()
+ {
+ var state = await RunScriptAsync("{ $a = 1\n$b = ($a + 1)\n$c = ($b + 1) }");
+ Assert.False(state.IsError);
+ Assert.Equal(1, GetInt("a"));
+ Assert.Equal(2, GetInt("b"));
+ Assert.Equal(3, GetInt("c"));
+ }
+
+ [Fact]
+ public async Task Assignment_ChainedArithmetic_ComputesExpected()
+ {
+ var state = await RunScriptAsync("$x = ((2 + 3) * 4 - 1)");
+ Assert.False(state.IsError);
+ Assert.Equal(19, GetInt("x"));
+ }
+
+ [Fact]
+ public async Task For_OverStrings_BindsTextElements()
+ {
+ var state = await RunScriptAsync("for $x in [\"a\", \"b\"] { }");
+ Assert.False(state.IsError);
+ Assert.Equal("b", Assert.IsType(GetVariable("x")).Text);
+ }
+
+ [Fact]
+ public async Task For_OverBooleans_BindsBoolElements()
+ {
+ var state = await RunScriptAsync("for $x in [true, false] { }");
+ Assert.False(state.IsError);
+ Assert.False(Assert.IsType(GetVariable("x")).Value);
+ }
+
+ [Fact]
+ public async Task For_OverNull_BindsNullAsText()
+ {
+ var state = await RunScriptAsync("for $x in [null] { }");
+ Assert.False(state.IsError);
+ Assert.Equal("null", Assert.IsType(GetVariable("x")).Text);
+ }
+
+ [Fact]
+ public async Task For_OverObjects_BindsJsonElements()
+ {
+ var state = await RunScriptAsync("for $x in [{ id: 1 }] { }");
+ Assert.False(state.IsError);
+ Assert.IsType(GetVariable("x"));
+ }
+
+ [Fact]
+ public async Task For_OverNestedArrays_BindsJsonElements()
+ {
+ var state = await RunScriptAsync("for $x in [[1, 2], [3, 4]] { }");
+ Assert.False(state.IsError);
+ Assert.IsType(GetVariable("x"));
+ }
+
+ [Fact]
+ public async Task For_OverNonArray_Throws()
+ {
+ await Assert.ThrowsAsync(
+ () => RunScriptAsync("for $x in 5 { }"));
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs b/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs
new file mode 100644
index 0000000..9736af1
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/StatementParserEdgeTests.cs
@@ -0,0 +1,136 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Collections.Generic;
+
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Covers structural parsing and error-recovery paths in
+/// that are not exercised by the happy-path statement tests: pipes, exec, function
+/// parameter lists, tolerant partial parsing, and recoverable syntax errors.
+///
+public class StatementParserEdgeTests
+{
+ private static List Parse(string input)
+ {
+ return new StatementParser(input).ParseStatements();
+ }
+
+ private static (List Statements, StatementParser Parser) ParseWithParser(string input)
+ {
+ var parser = new StatementParser(input);
+ return (parser.ParseStatements(), parser);
+ }
+
+ [Fact]
+ public void Pipe_TwoCommands_ProducesPipeStatement()
+ {
+ var statements = Parse("echo a | echo b");
+ var pipe = Assert.IsType(Assert.Single(statements));
+ Assert.Equal(2, pipe.Statements.Count);
+ }
+
+ [Fact]
+ public void Pipe_ThreeCommands_ProducesPipeStatementWithThreeSegments()
+ {
+ var statements = Parse("echo a | echo b | echo c");
+ var pipe = Assert.IsType(Assert.Single(statements));
+ Assert.Equal(3, pipe.Statements.Count);
+ }
+
+ [Fact]
+ public void Exec_WithArguments_ProducesExecStatement()
+ {
+ var statements = Parse("exec echo hi there");
+ Assert.IsType(Assert.Single(statements));
+ }
+
+ [Fact]
+ public void Def_WithParenParameters_ParsesParameters()
+ {
+ var statements = Parse("def add($a, $b) { return ($a + $b) }");
+ var def = Assert.IsType(Assert.Single(statements));
+ Assert.Equal("add", def.Name);
+ Assert.Equal(new[] { "a", "b" }, def.Parameters);
+ }
+
+ [Fact]
+ public void Def_WithBracketParameters_ParsesParameters()
+ {
+ var statements = Parse("def greet [name] { echo $name }");
+ var def = Assert.IsType(Assert.Single(statements));
+ Assert.Equal("greet", def.Name);
+ Assert.Equal(new[] { "name" }, def.Parameters);
+ }
+
+ [Fact]
+ public void Def_WithoutParameters_ParsesEmptyParameterList()
+ {
+ var statements = Parse("def now() { echo hi }");
+ var def = Assert.IsType(Assert.Single(statements));
+ Assert.Empty(def.Parameters);
+ }
+
+ [Fact]
+ public void MultipleStatements_SeparatedBySemicolons_AreAllParsed()
+ {
+ var statements = Parse("$x = 1; $y = 2; $z = 3");
+ Assert.Equal(3, statements.Count);
+ Assert.All(statements, s => Assert.IsType(s));
+ }
+
+ [Fact]
+ public void Comment_IsRecorded_AndDoesNotBecomeStatement()
+ {
+ var (statements, parser) = ParseWithParser("echo a # trailing comment\necho b");
+ Assert.Equal(2, statements.Count);
+ Assert.NotEmpty(parser.Comments);
+ }
+
+ [Fact]
+ public void EmptyInput_ProducesNoStatements()
+ {
+ Assert.Empty(Parse(" \n \t "));
+ }
+
+ [Fact]
+ public void UnexpectedCloseBrace_ReportsErrorAndDoesNotThrow()
+ {
+ var (_, parser) = ParseWithParser("}");
+ Assert.NotEmpty(parser.Errors);
+ }
+
+ [Fact]
+ public void IncompleteBlock_WithoutToleration_ReportsErrorAndDropsStatement()
+ {
+ var (statements, parser) = ParseWithParser("{ echo a");
+ Assert.Empty(statements);
+ Assert.NotEmpty(parser.Errors);
+ }
+
+ [Fact]
+ public void IncompleteBlock_WithToleration_ReturnsPartialBlock()
+ {
+ var parser = new StatementParser("{ echo a")
+ {
+ TolerateIncompleteConstructs = true,
+ };
+
+ var statements = parser.ParseStatements();
+
+ var block = Assert.IsType(Assert.Single(statements));
+ Assert.Single(block.Statements);
+ Assert.NotEmpty(parser.Errors);
+ }
+
+ [Fact]
+ public void Pipe_WithMissingRightHandSide_ReportsError()
+ {
+ var (_, parser) = ParseWithParser("echo a |");
+ Assert.NotEmpty(parser.Errors);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs b/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs
new file mode 100644
index 0000000..ff5730c
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/StatementParserStructureTests.cs
@@ -0,0 +1,117 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Collections.Generic;
+using System.Linq;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Covers the append-redirect synthesis (>>, 2>>), duplicate and
+/// missing-destination redirect error branches, option :/=/-- value
+/// syntax, and JSON-path variable parsing ($x.name, $items[0], $.name)
+/// in / .
+///
+public class StatementParserStructureTests
+{
+ private static CommandStatement ParseCommand(string input)
+ {
+ var statement = new StatementParser(input).ParseStatements().Single();
+ return Assert.IsType(statement);
+ }
+
+ private static (List Statements, IReadOnlyList Errors) ParseWithErrors(string input)
+ {
+ var lexer = new Lexer(input);
+ var parser = new StatementParser(lexer);
+ return (parser.ParseStatements(), lexer.Errors);
+ }
+
+ [Fact]
+ public void AppendOutputRedirect_IsRecognized()
+ {
+ var cmd = ParseCommand("query \"SELECT * FROM c\" >> results.json");
+ Assert.True(cmd.AppendOutput);
+ Assert.Equal("results.json", cmd.OutputRedirect);
+ }
+
+ [Fact]
+ public void AppendErrorRedirect_IsRecognized()
+ {
+ var cmd = ParseCommand("query \"SELECT * FROM c\" 2>> errors.log");
+ Assert.True(cmd.AppendError);
+ Assert.Equal("errors.log", cmd.ErrorRedirect);
+ }
+
+ [Fact]
+ public void DuplicateOutputRedirect_ReportsError()
+ {
+ var (_, errors) = ParseWithErrors("query \"q\" > a.json > b.json");
+ Assert.NotEmpty(errors);
+ }
+
+ [Fact]
+ public void DuplicateErrorRedirect_ReportsError()
+ {
+ var (_, errors) = ParseWithErrors("query \"q\" 2> a.log 2> b.log");
+ Assert.NotEmpty(errors);
+ }
+
+ [Fact]
+ public void OutputRedirect_MissingDestination_ReportsError()
+ {
+ var (_, errors) = ParseWithErrors("query \"q\" >");
+ Assert.NotEmpty(errors);
+ }
+
+ [Fact]
+ public void Option_WithColonValue_ParsesValue()
+ {
+ var cmd = ParseCommand("query \"q\" -max:10");
+ var option = cmd.Arguments.OfType().Single();
+ Assert.Equal("max", option.Name);
+ Assert.Equal("10", option.Value?.ToString());
+ }
+
+ [Fact]
+ public void Option_WithEqualsValue_ParsesValue()
+ {
+ var cmd = ParseCommand("query \"q\" --max=25");
+ var option = cmd.Arguments.OfType().Single();
+ Assert.Equal("max", option.Name);
+ Assert.Equal("25", option.Value?.ToString());
+ }
+
+ [Fact]
+ public void Variable_WithPropertyAccess_ParsesAsJsonPath()
+ {
+ var expr = new ExpressionParser(new Lexer("$script.name")).ParseExpression();
+ Assert.IsType(expr);
+ }
+
+ [Fact]
+ public void Variable_WithArrayAccess_ParsesAsJsonPath()
+ {
+ var expr = new ExpressionParser(new Lexer("$items[0]")).ParseExpression();
+ Assert.IsType(expr);
+ }
+
+ [Fact]
+ public void PipedResultPath_ParsesAsJsonPath()
+ {
+ var expr = new ExpressionParser(new Lexer("$.name")).ParseExpression();
+ Assert.IsType(expr);
+ }
+
+ [Fact]
+ public void PlainVariable_ParsesAsVariableExpression()
+ {
+ var expr = new ExpressionParser(new Lexer("$foo")).ParseExpression();
+ var variable = Assert.IsType(expr);
+ Assert.Equal("foo", variable.Name);
+ }
+}
diff --git a/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs b/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs
new file mode 100644
index 0000000..0378f0b
--- /dev/null
+++ b/CosmosDBShell.Tests/Parser/StatementPositionalErrorTests.cs
@@ -0,0 +1,76 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests.Parser;
+
+using System.Threading;
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Parser;
+
+///
+/// Drives the error-wrapping branches in the loop statements: when a loop body throws
+/// and the shell carries script context, the exception is re-thrown as a
+/// . Nested loops also exercise the
+/// catch (PositionalException) re-throw branch.
+///
+public class StatementPositionalErrorTests : TestBase
+{
+ private async Task RunWithScriptContextAsync(string script)
+ {
+ Shell.CurrentScriptFileName = "script.csh";
+ Shell.CurrentScriptContent = script;
+
+ var statements = new StatementParser(script).ParseStatements();
+ var state = new CommandState();
+ foreach (var statement in statements)
+ {
+ state = await statement.RunAsync(Shell, state, CancellationToken.None);
+ }
+ }
+
+ [Fact]
+ public async Task While_BodyThrows_WithScriptContext_WrapsInPositionalException()
+ {
+ var ex = await Assert.ThrowsAsync(
+ () => RunWithScriptContextAsync("while true { totallyunknowncmd999 }"));
+ Assert.Equal("script.csh", ex.FileName);
+ }
+
+ [Fact]
+ public async Task DoWhile_BodyThrows_WithScriptContext_WrapsInPositionalException()
+ {
+ var ex = await Assert.ThrowsAsync(
+ () => RunWithScriptContextAsync("do { totallyunknowncmd999 } while false"));
+ Assert.Equal("script.csh", ex.FileName);
+ }
+
+ [Fact]
+ public async Task Loop_BodyThrows_WithScriptContext_WrapsInPositionalException()
+ {
+ var ex = await Assert.ThrowsAsync(
+ () => RunWithScriptContextAsync("loop { totallyunknowncmd999 }"));
+ Assert.Equal("script.csh", ex.FileName);
+ }
+
+ [Fact]
+ public async Task For_BodyThrows_WithScriptContext_WrapsInPositionalException()
+ {
+ var ex = await Assert.ThrowsAsync(
+ () => RunWithScriptContextAsync("for $x in [1] { totallyunknowncmd999 }"));
+ Assert.Equal("script.csh", ex.FileName);
+ }
+
+ [Fact]
+ public async Task NestedLoops_InnerWraps_OuterRethrowsPositionalException()
+ {
+ // The inner loop wraps the failure into a PositionalException; the outer loop's
+ // catch (PositionalException) branch re-throws it unchanged.
+ var ex = await Assert.ThrowsAsync(
+ () => RunWithScriptContextAsync(
+ "for $x in [1] { for $y in [1] { totallyunknowncmd999 } }"));
+ Assert.Equal("script.csh", ex.FileName);
+ }
+}
diff --git a/CosmosDBShell.Tests/ResourceOperationsTests.cs b/CosmosDBShell.Tests/ResourceOperationsTests.cs
new file mode 100644
index 0000000..b432e60
--- /dev/null
+++ b/CosmosDBShell.Tests/ResourceOperationsTests.cs
@@ -0,0 +1,34 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests;
+
+using Azure.Data.Cosmos.Shell.Mcp;
+
+public class ResourceOperationsTests
+{
+ [Fact]
+ public void GetScriptingGuide_ReturnsEmbeddedProgrammingMarkdown()
+ {
+ var guide = ResourceOperations.GetScriptingGuide();
+
+ Assert.False(string.IsNullOrWhiteSpace(guide));
+ Assert.Contains("#", guide);
+ }
+
+ [Fact]
+ public void GetQueryLanguageReference_ReturnsEmbeddedQueryLanguageMarkdown()
+ {
+ var reference = ResourceOperations.GetQueryLanguageReference();
+
+ Assert.False(string.IsNullOrWhiteSpace(reference));
+ Assert.Contains("SELECT", reference, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetScriptingGuide_IsStableAcrossCalls()
+ {
+ Assert.Equal(ResourceOperations.GetScriptingGuide(), ResourceOperations.GetScriptingGuide());
+ }
+}
diff --git a/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs b/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs
index 499b822..4767da1 100644
--- a/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs
+++ b/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs
@@ -600,10 +600,99 @@ public void ReverseSearch_FormatSearchPromptMarkup_TruncatesLongQueryToFitMaxWid
Assert.Contains("...", text);
}
+ [Fact]
+ public void ClearCurrentLine_RemovesAllContent()
+ {
+ var context = CreateContext("hello world", position: 5);
+
+ new ClearCurrentLineCommand().Execute(context);
+
+ Assert.Equal(string.Empty, context.Buffer.Content);
+ Assert.Equal(0, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void ClearCurrentLine_EmptyBuffer_NoOp()
+ {
+ var context = CreateContext(string.Empty, position: 0);
+
+ new ClearCurrentLineCommand().Execute(context);
+
+ Assert.Equal(string.Empty, context.Buffer.Content);
+ Assert.Equal(0, context.Buffer.Position);
+ }
+
+ [Fact]
+ public void ClearScreen_DoesNotThrowAndPreservesBuffer()
+ {
+ var context = CreateContext("hello", position: 2);
+
+ var saved = AnsiConsole.Console;
+ try
+ {
+ AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Ansi = AnsiSupport.Yes,
+ ColorSystem = ColorSystemSupport.NoColors,
+ Out = new AnsiConsoleOutput(new StringWriter()),
+ });
+
+ var ex = Record.Exception(() => new ClearScreenCommand().Execute(context));
+
+ Assert.Null(ex);
+ Assert.Equal("hello", context.Buffer.Content);
+ }
+ finally
+ {
+ AnsiConsole.Console = saved;
+ }
+ }
+
+ [Fact]
+ public void ReverseSearch_FindInitialForwardMatch_NoMatch_ReturnsNone()
+ {
+ var history = new[] { "ls", "echo hi" };
+
+ var result = ReverseHistorySearch.FindInitialForwardMatch(history, "query");
+
+ Assert.False(result.HasMatch);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindInitialForwardMatch_ReturnsOldestMatch()
+ {
+ var history = new[] { "query a", "ls", "query b" };
+
+ var result = ReverseHistorySearch.FindInitialForwardMatch(history, "query");
+
+ Assert.True(result.HasMatch);
+ Assert.Equal("query a", result.Match);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindPreviousMatch_NoMatch_ReturnsNone()
+ {
+ var history = new[] { "ls", "echo hi" };
+
+ var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 0);
+
+ Assert.False(result.HasMatch);
+ }
+
+ [Fact]
+ public void ReverseSearch_FindInitialMatch_NoMatch_ReturnsNone()
+ {
+ var history = new[] { "ls", "echo hi" };
+
+ var result = ReverseHistorySearch.FindInitialMatch(history, "query");
+
+ Assert.False(result.HasMatch);
+ }
+
private static LineEditorContext CreateContext(string content, int position)
{
var buffer = new LineBuffer(content);
buffer.Move(position);
return new LineEditorContext(buffer, null!);
}
-}
+}
\ No newline at end of file
diff --git a/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs b/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs
new file mode 100644
index 0000000..a24b245
--- /dev/null
+++ b/CosmosDBShell.Tests/ToolOperationsCallToolTests.cs
@@ -0,0 +1,219 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// ------------------------------------------------------------
+
+namespace CosmosShell.Tests;
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Azure.Data.Cosmos.Shell.Core;
+using Azure.Data.Cosmos.Shell.Mcp;
+
+using Microsoft.Extensions.Logging.Abstractions;
+
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+
+using Spectre.Console;
+
+// Exercises ToolOperations.CallToolHandler / ListToolsHandler against the shared
+// ShellInterpreter.Instance singleton. Placed in the theme-state collection so the
+// success path (which writes the highlighted command line through AnsiConsole) does
+// not race with other tests that swap the global console or theme.
+[Collection(CosmosShell.Tests.Shell.ThemeStateTestCollection.Name)]
+public class ToolOperationsCallToolTests
+{
+ private static ToolOperations CreateToolOperations()
+ {
+ return new ToolOperations(NullLogger.Instance);
+ }
+
+ private static RequestContext CallContext(string? name, Dictionary? arguments = null)
+ {
+ var context = (RequestContext)RuntimeHelpers.GetUninitializedObject(
+ typeof(RequestContext));
+ if (name != null)
+ {
+ context.Params = new CallToolRequestParams { Name = name, Arguments = arguments };
+ }
+
+ return context;
+ }
+
+ private static RequestContext ListContext()
+ {
+ return (RequestContext)RuntimeHelpers.GetUninitializedObject(
+ typeof(RequestContext));
+ }
+
+ private static JsonElement Json(string raw)
+ {
+ using var document = JsonDocument.Parse(raw);
+ return document.RootElement.Clone();
+ }
+
+ private static (bool IsError, JsonElement Root, JsonDocument Document) ReadResult(CallToolResult result)
+ {
+ var text = Assert.IsType(Assert.Single(result.Content)).Text;
+ var document = JsonDocument.Parse(text);
+ return (result.IsError == true, document.RootElement, document);
+ }
+
+ [Fact]
+ public async Task CallTool_NullParams_ReturnsError()
+ {
+ var tool = CreateToolOperations();
+
+ var result = await tool.CallToolHandler(CallContext(null), CancellationToken.None);
+
+ var (isError, root, document) = ReadResult(result);
+ using (document)
+ {
+ Assert.True(isError);
+ Assert.Contains("null parameters", root.GetProperty("error").GetString());
+ }
+ }
+
+ [Fact]
+ public async Task CallTool_UnknownCommand_ReturnsError()
+ {
+ var tool = CreateToolOperations();
+
+ var result = await tool.CallToolHandler(CallContext("definitely-not-a-command"), CancellationToken.None);
+
+ var (isError, root, document) = ReadResult(result);
+ using (document)
+ {
+ Assert.True(isError);
+ Assert.Contains("Could not find command", root.GetProperty("error").GetString());
+ }
+ }
+
+ [Fact]
+ public async Task CallTool_RestrictedCommand_ReturnsError()
+ {
+ var tool = CreateToolOperations();
+ Assert.True(ShellInterpreter.Instance.App.Commands["delete"].McpRestricted);
+
+ var result = await tool.CallToolHandler(CallContext("delete"), CancellationToken.None);
+
+ var (isError, root, document) = ReadResult(result);
+ using (document)
+ {
+ Assert.True(isError);
+ Assert.Contains("restricted for MCP", root.GetProperty("error").GetString());
+ }
+ }
+
+ [Fact]
+ public async Task CallTool_UnknownArgument_ReturnsErrorListingKnownArguments()
+ {
+ var tool = CreateToolOperations();
+ var arguments = new Dictionary
+ {
+ ["bogus"] = Json("\"value\""),
+ };
+
+ var result = await tool.CallToolHandler(CallContext("echo", arguments), CancellationToken.None);
+
+ var (isError, root, document) = ReadResult(result);
+ using (document)
+ {
+ Assert.True(isError);
+ var error = root.GetProperty("error").GetString();
+ Assert.Contains("Unknown argument 'bogus'", error);
+ Assert.Contains("Known arguments:", error);
+ }
+ }
+
+ [Fact]
+ public async Task CallTool_MissingRequiredParameter_ReturnsError()
+ {
+ var tool = CreateToolOperations();
+
+ // 'query' requires the 'query' parameter; supplying none triggers the missing-required path.
+ var result = await tool.CallToolHandler(CallContext("query"), CancellationToken.None);
+
+ var (isError, root, document) = ReadResult(result);
+ using (document)
+ {
+ Assert.True(isError);
+ Assert.Contains("Missing required parameter", root.GetProperty("error").GetString());
+ }
+ }
+
+ [Fact]
+ public async Task CallTool_InvalidValueType_ReturnsSanitizedError()
+ {
+ var tool = CreateToolOperations();
+ var arguments = new Dictionary
+ {
+ // 'max' is an integer option; a non-numeric string cannot convert.
+ ["max"] = Json("\"not-a-number\""),
+ };
+
+ var result = await tool.CallToolHandler(CallContext("query", arguments), CancellationToken.None);
+
+ var (isError, root, document) = ReadResult(result);
+ using (document)
+ {
+ Assert.True(isError);
+ var error = root.GetProperty("error").GetString();
+ Assert.Contains("Invalid value for option '--max'", error);
+ // The offending raw value must never be echoed back (secret-redaction contract).
+ Assert.DoesNotContain("not-a-number", error);
+ }
+ }
+
+ [Fact]
+ public async Task CallTool_EchoCommand_ReturnsSuccessResult()
+ {
+ var tool = CreateToolOperations();
+ var arguments = new Dictionary
+ {
+ ["messages"] = Json("[\"hello\", \"world\"]"),
+ };
+
+ var saved = AnsiConsole.Console;
+ try
+ {
+ AnsiConsole.Console = AnsiConsole.Create(new AnsiConsoleSettings
+ {
+ Ansi = AnsiSupport.Yes,
+ ColorSystem = ColorSystemSupport.NoColors,
+ Out = new AnsiConsoleOutput(new StringWriter()),
+ });
+
+ var result = await tool.CallToolHandler(CallContext("echo", arguments), CancellationToken.None);
+
+ var (isError, root, document) = ReadResult(result);
+ using (document)
+ {
+ Assert.False(isError);
+ Assert.Equal("hello world", root.GetProperty("result").GetString());
+ Assert.True(root.TryGetProperty("currentLocation", out _));
+ }
+ }
+ finally
+ {
+ AnsiConsole.Console = saved;
+ }
+ }
+
+ [Fact]
+ public async Task ListTools_ReturnsRegisteredTools()
+ {
+ var tool = CreateToolOperations();
+
+ var result = await tool.ListToolsHandler(ListContext(), CancellationToken.None);
+
+ Assert.NotEmpty(result.Tools);
+ Assert.Contains(result.Tools, t => t.Name == "query");
+ Assert.Contains(result.Tools, t => t.Name == "echo");
+ }
+}
diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ShellObject/ShellObject.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ShellObject/ShellObject.cs
index d4ce491..9b90aa9 100644
--- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ShellObject/ShellObject.cs
+++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Parser/ShellObject/ShellObject.cs
@@ -4,6 +4,7 @@
namespace Azure.Data.Cosmos.Shell.Parser;
+using System.Globalization;
using Azure.Data.Cosmos.Shell.Core;
using Azure.Data.Cosmos.Shell.Parser;
@@ -53,6 +54,14 @@ public static ShellObject Parse(Lexer lexer)
case TokenType.String:
// Quoted strings are always text
return new ShellText(token.Value);
+ case TokenType.Number:
+ return int.TryParse(token.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int numberValue)
+ ? new ShellNumber(numberValue)
+ : new ShellIdentifier(token.Value);
+ case TokenType.Decimal:
+ return double.TryParse(token.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double decimalValue)
+ ? new ShellDecimal(decimalValue)
+ : new ShellIdentifier(token.Value);
default:
// For any other token type, treat as text
return new ShellIdentifier(token.Value);
diff --git a/tools/coverage.ps1 b/tools/coverage.ps1
new file mode 100644
index 0000000..af3a0fd
--- /dev/null
+++ b/tools/coverage.ps1
@@ -0,0 +1,94 @@
+#!/usr/bin/env pwsh
+<#
+.SYNOPSIS
+ Runs the unit tests with code coverage and generates a human readable report.
+
+.DESCRIPTION
+ Collects coverage with coverlet (via "dotnet test --collect") and renders the
+ result with ReportGenerator. The report contains an overall summary as well as
+ a per-namespace and per-class breakdown.
+
+ An interactive HTML report is written to the output directory and a text summary
+ is printed to the console. The HTML report has a "Group by" selector that allows
+ grouping the coverage numbers by namespace.
+
+.PARAMETER Configuration
+ Build configuration to test. Defaults to "Debug".
+
+.PARAMETER Output
+ Directory for the generated coverage report. Defaults to "TestResults/coverage".
+
+.PARAMETER NoOpen
+ Do not open the generated HTML report in the default browser.
+
+.EXAMPLE
+ ./tools/coverage.ps1
+
+.EXAMPLE
+ ./tools/coverage.ps1 -Configuration Release -NoOpen
+#>
+[CmdletBinding()]
+param(
+ [string]$Configuration = 'Debug',
+ [string]$Output = 'TestResults/coverage',
+ [switch]$NoOpen
+)
+
+$ErrorActionPreference = 'Stop'
+
+$repoRoot = Split-Path -Parent $PSScriptRoot
+Push-Location $repoRoot
+try {
+ $testProject = Join-Path $repoRoot 'CosmosDBShell.Tests/CosmosDBShell.Tests.csproj'
+ $resultsDir = Join-Path $repoRoot 'TestResults/coverage-raw'
+ $reportDir = Join-Path $repoRoot $Output
+
+ if (Test-Path $resultsDir) {
+ Remove-Item -Recurse -Force $resultsDir
+ }
+
+ Write-Host 'Restoring local dotnet tools...' -ForegroundColor Cyan
+ dotnet tool restore
+ if ($LASTEXITCODE -ne 0) { throw "dotnet tool restore failed with exit code $LASTEXITCODE." }
+
+ Write-Host 'Running tests with coverage...' -ForegroundColor Cyan
+ dotnet test $testProject `
+ --configuration $Configuration `
+ --collect:'XPlat Code Coverage' `
+ --results-directory $resultsDir
+ if ($LASTEXITCODE -ne 0) { throw "dotnet test failed with exit code $LASTEXITCODE." }
+
+ $coverageFiles = Get-ChildItem -Path $resultsDir -Recurse -Filter 'coverage.cobertura.xml'
+ if (-not $coverageFiles) {
+ throw "No coverage files found under '$resultsDir'."
+ }
+
+ Write-Host 'Generating coverage report...' -ForegroundColor Cyan
+ $reports = ($coverageFiles.FullName -join ';')
+ dotnet tool run reportgenerator `
+ "-reports:$reports" `
+ "-targetdir:$reportDir" `
+ '-reporttypes:Html;TextSummary' `
+ '-title:CosmosDBShell Code Coverage'
+ if ($LASTEXITCODE -ne 0) { throw "reportgenerator failed with exit code $LASTEXITCODE." }
+
+ $summaryFile = Join-Path $reportDir 'Summary.txt'
+ if (Test-Path $summaryFile) {
+ Write-Host ''
+ Get-Content $summaryFile | Write-Host
+ }
+
+ $htmlReport = Join-Path $reportDir 'index.html'
+ Write-Host ''
+ Write-Host "HTML report: $htmlReport" -ForegroundColor Green
+ Write-Host 'Tip: use the "Group by" selector in the report to view coverage per namespace.' -ForegroundColor DarkGray
+
+ if (-not $NoOpen -and (Test-Path $htmlReport)) {
+ if ($IsWindows -or $null -eq $IsWindows) {
+ Start-Process $htmlReport
+ }
+ }
+}
+finally {
+ Pop-Location
+}