diff --git a/Foundation.Data.Doublets.Cli.Tests/Foundation.Data.Doublets.Cli.Tests.csproj b/Foundation.Data.Doublets.Cli.Tests/Foundation.Data.Doublets.Cli.Tests.csproj
index cade85a..52b2df7 100644
--- a/Foundation.Data.Doublets.Cli.Tests/Foundation.Data.Doublets.Cli.Tests.csproj
+++ b/Foundation.Data.Doublets.Cli.Tests/Foundation.Data.Doublets.Cli.Tests.csproj
@@ -14,6 +14,8 @@
+
+
diff --git a/Foundation.Data.Doublets.Cli.Tests/ServerModeIntegrationTests.cs b/Foundation.Data.Doublets.Cli.Tests/ServerModeIntegrationTests.cs
new file mode 100644
index 0000000..4b8e7ee
--- /dev/null
+++ b/Foundation.Data.Doublets.Cli.Tests/ServerModeIntegrationTests.cs
@@ -0,0 +1,129 @@
+using Xunit;
+using System.Diagnostics;
+using System.IO;
+
+namespace Foundation.Data.Doublets.Cli.Tests
+{
+ public class ServerModeIntegrationTests : IDisposable
+ {
+ private string _tempDbFile;
+
+ public ServerModeIntegrationTests()
+ {
+ _tempDbFile = Path.GetTempFileName();
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(_tempDbFile))
+ {
+ File.Delete(_tempDbFile);
+ }
+ }
+
+ [Fact]
+ public void Should_Show_Server_Option_In_Help()
+ {
+ // Arrange & Act
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ Arguments = $"run --project {GetCliProjectPath()} -- --help",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ }
+ };
+
+ process.Start();
+ var output = process.StandardOutput.ReadToEnd();
+ process.WaitForExit();
+
+ // Assert
+ Assert.Contains("--server", output);
+ Assert.Contains("Start server listening on a port", output);
+ Assert.Contains("--port", output);
+ Assert.Contains("Port to listen on when in server mode", output);
+ }
+
+ [Fact]
+ public void Should_Start_Server_With_Correct_Output()
+ {
+ // Arrange & Act
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ Arguments = $"run --project {GetCliProjectPath()} -- --server --port 0 --db {_tempDbFile}",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ }
+ };
+
+ process.Start();
+
+ // Wait a short time to capture startup output
+ var outputBuffer = "";
+ var startTime = DateTime.Now;
+
+ while ((DateTime.Now - startTime).TotalSeconds < 3)
+ {
+ if (!process.StandardOutput.EndOfStream)
+ {
+ outputBuffer += process.StandardOutput.ReadLine() + "\n";
+ }
+
+ if (outputBuffer.Contains("LiNo WebSocket server started"))
+ {
+ break;
+ }
+
+ Thread.Sleep(100);
+ }
+
+ process.Kill();
+ process.WaitForExit();
+
+ // Assert
+ Assert.Contains("LiNo WebSocket server started", outputBuffer);
+ Assert.Contains("Press Ctrl+C to stop the server", outputBuffer);
+ }
+
+ [Fact]
+ public void Should_Preserve_Normal_CLI_Functionality()
+ {
+ // Arrange & Act
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ Arguments = $"run --project {GetCliProjectPath()} -- --db {_tempDbFile} '() ((1 1))' --changes --after",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true
+ }
+ };
+
+ process.Start();
+ var output = process.StandardOutput.ReadToEnd();
+ process.WaitForExit();
+
+ // Assert - Normal CLI functionality should work
+ Assert.Equal(0, process.ExitCode);
+ Assert.Contains("(1: 1 1)", output); // Should show created link
+ }
+
+ private string GetCliProjectPath()
+ {
+ // Get the current test directory and navigate to the CLI project
+ var currentDir = Directory.GetCurrentDirectory();
+ var projectRoot = Directory.GetParent(currentDir)?.Parent?.Parent?.FullName;
+ return Path.Combine(projectRoot ?? currentDir, "Foundation.Data.Doublets.Cli");
+ }
+ }
+}
\ No newline at end of file
diff --git a/Foundation.Data.Doublets.Cli.Tests/ServerModeTests.cs b/Foundation.Data.Doublets.Cli.Tests/ServerModeTests.cs
new file mode 100644
index 0000000..49ad625
--- /dev/null
+++ b/Foundation.Data.Doublets.Cli.Tests/ServerModeTests.cs
@@ -0,0 +1,360 @@
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Foundation.Data.Doublets.Cli;
+using System.IO;
+using Xunit;
+using Platform.Data.Doublets;
+
+namespace Foundation.Data.Doublets.Cli.Tests
+{
+ public class ServerModeTests : IDisposable
+ {
+ private string _tempDbFile;
+
+ public ServerModeTests()
+ {
+ _tempDbFile = Path.GetTempFileName();
+ }
+
+ public void Dispose()
+ {
+ if (File.Exists(_tempDbFile))
+ {
+ File.Delete(_tempDbFile);
+ }
+ }
+
+ [Fact]
+ public async Task Should_Accept_WebSocket_Connection()
+ {
+ // Arrange
+ var builder = WebApplication.CreateSlimBuilder();
+ builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false));
+ var app = builder.Build();
+ app.UseWebSockets();
+ app.Map("/ws", HandleWebSocketEndpoint);
+
+ using var server = new TestWebSocketServer(app);
+ await server.StartAsync();
+
+ // Act & Assert
+ using var client = new ClientWebSocket();
+ var uri = new Uri($"ws://localhost:{server.Port}/ws");
+
+ // Should not throw exception
+ await client.ConnectAsync(uri, CancellationToken.None);
+ Assert.Equal(WebSocketState.Open, client.State);
+
+ await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Should_Process_LiNo_Query_Via_WebSocket()
+ {
+ // Arrange
+ var builder = WebApplication.CreateSlimBuilder();
+ builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false));
+ var app = builder.Build();
+ app.UseWebSockets();
+ app.Map("/ws", HandleWebSocketEndpoint);
+
+ using var server = new TestWebSocketServer(app);
+ await server.StartAsync();
+
+ using var client = new ClientWebSocket();
+ var uri = new Uri($"ws://localhost:{server.Port}/ws");
+ await client.ConnectAsync(uri, CancellationToken.None);
+
+ // Act - Send a create link query
+ var query = "() ((1 1))";
+ var queryBytes = Encoding.UTF8.GetBytes(query);
+ await client.SendAsync(new ArraySegment(queryBytes), WebSocketMessageType.Text, true, CancellationToken.None);
+
+ // Assert - Receive and verify response
+ var buffer = new byte[1024 * 4];
+ var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None);
+
+ Assert.Equal(WebSocketMessageType.Text, result.MessageType);
+
+ var responseText = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ var response = JsonSerializer.Deserialize(responseText);
+
+ Assert.True(response.TryGetProperty("query", out var queryProp));
+ Assert.Equal(query, queryProp.GetString());
+
+ Assert.True(response.TryGetProperty("changes", out var changesProp));
+ Assert.True(changesProp.GetArrayLength() > 0);
+
+ Assert.True(response.TryGetProperty("links", out var linksProp));
+ Assert.True(linksProp.GetArrayLength() > 0);
+
+ await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Should_Handle_Multiple_Queries()
+ {
+ // Arrange
+ var builder = WebApplication.CreateSlimBuilder();
+ builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false));
+ var app = builder.Build();
+ app.UseWebSockets();
+ app.Map("/ws", HandleWebSocketEndpoint);
+
+ using var server = new TestWebSocketServer(app);
+ await server.StartAsync();
+
+ using var client = new ClientWebSocket();
+ var uri = new Uri($"ws://localhost:{server.Port}/ws");
+ await client.ConnectAsync(uri, CancellationToken.None);
+
+ // Act & Assert - Send multiple queries
+ var queries = new[] { "() ((1 1))", "() ((2 2))", "((($i:)) (($i:)))" };
+
+ foreach (var query in queries)
+ {
+ var queryBytes = Encoding.UTF8.GetBytes(query);
+ await client.SendAsync(new ArraySegment(queryBytes), WebSocketMessageType.Text, true, CancellationToken.None);
+
+ var buffer = new byte[1024 * 4];
+ var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None);
+
+ Assert.Equal(WebSocketMessageType.Text, result.MessageType);
+
+ var responseText = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ var response = JsonSerializer.Deserialize(responseText);
+
+ Assert.True(response.TryGetProperty("query", out var queryProp));
+ Assert.Equal(query, queryProp.GetString());
+ }
+
+ await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Should_Return_Error_For_Invalid_Query()
+ {
+ // Arrange
+ var builder = WebApplication.CreateSlimBuilder();
+ builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false));
+ var app = builder.Build();
+ app.UseWebSockets();
+ app.Map("/ws", HandleWebSocketEndpoint);
+
+ using var server = new TestWebSocketServer(app);
+ await server.StartAsync();
+
+ using var client = new ClientWebSocket();
+ var uri = new Uri($"ws://localhost:{server.Port}/ws");
+ await client.ConnectAsync(uri, CancellationToken.None);
+
+ // Act - Send an invalid query
+ var invalidQuery = "((invalid query))";
+ var queryBytes = Encoding.UTF8.GetBytes(invalidQuery);
+ await client.SendAsync(new ArraySegment(queryBytes), WebSocketMessageType.Text, true, CancellationToken.None);
+
+ // Assert - Should receive error response
+ var buffer = new byte[1024 * 4];
+ var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None);
+
+ Assert.Equal(WebSocketMessageType.Text, result.MessageType);
+
+ var responseText = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ var response = JsonSerializer.Deserialize(responseText);
+
+ Assert.True(response.TryGetProperty("error", out _));
+
+ await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Should_Reject_Non_WebSocket_Requests()
+ {
+ // Arrange
+ var builder = WebApplication.CreateSlimBuilder();
+ builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false));
+ var app = builder.Build();
+ app.UseWebSockets();
+ app.Map("/ws", HandleWebSocketEndpoint);
+
+ using var server = new TestWebSocketServer(app);
+ await server.StartAsync();
+
+ // Act & Assert - Regular HTTP request should be rejected
+ using var httpClient = new HttpClient();
+ var response = await httpClient.GetAsync($"http://localhost:{server.Port}/ws");
+
+ Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ // Helper method - extracted from Program.cs
+ private static async Task HandleWebSocketEndpoint(Microsoft.AspNetCore.Http.HttpContext context)
+ {
+ if (!context.WebSockets.IsWebSocketRequest)
+ {
+ context.Response.StatusCode = 400;
+ return;
+ }
+
+ var decoratedLinks = context.RequestServices.GetRequiredService>();
+ using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
+ await HandleWebSocketConnection(webSocket, decoratedLinks);
+ }
+
+ // Helper method - extracted from Program.cs
+ private static async Task HandleWebSocketConnection(WebSocket webSocket, NamedLinksDecorator decoratedLinks)
+ {
+ var buffer = new byte[1024 * 4];
+
+ while (webSocket.State == WebSocketState.Open)
+ {
+ try
+ {
+ var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
+ break;
+ }
+
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ var query = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ var response = await ProcessLinoQuery(query, decoratedLinks);
+
+ var responseBytes = Encoding.UTF8.GetBytes(response);
+ await webSocket.SendAsync(
+ new ArraySegment(responseBytes),
+ WebSocketMessageType.Text,
+ true,
+ CancellationToken.None
+ );
+ }
+ }
+ catch (WebSocketException)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ var errorResponse = JsonSerializer.Serialize(new { error = ex.Message });
+ var errorBytes = Encoding.UTF8.GetBytes(errorResponse);
+
+ try
+ {
+ await webSocket.SendAsync(
+ new ArraySegment(errorBytes),
+ WebSocketMessageType.Text,
+ true,
+ CancellationToken.None
+ );
+ }
+ catch (WebSocketException)
+ {
+ break;
+ }
+ }
+ }
+ }
+
+ // Helper method - extracted from Program.cs
+ private static async Task ProcessLinoQuery(string query, NamedLinksDecorator decoratedLinks)
+ {
+ return await Task.Run(() =>
+ {
+ try
+ {
+ var changesList = new List<(Platform.Data.Doublets.Link Before, Platform.Data.Doublets.Link After)>();
+ var results = new List();
+
+ if (!string.IsNullOrWhiteSpace(query))
+ {
+ var options = new AdvancedMixedQueryProcessor.Options
+ {
+ Query = query,
+ Trace = false,
+ ChangesHandler = (beforeLink, afterLink) =>
+ {
+ changesList.Add((new Platform.Data.Doublets.Link(beforeLink), new Platform.Data.Doublets.Link(afterLink)));
+ return decoratedLinks.Constants.Continue;
+ }
+ };
+
+ AdvancedMixedQueryProcessor.ProcessQuery(decoratedLinks, options);
+
+ // Collect current state of all links
+ var any = decoratedLinks.Constants.Any;
+ var linkQuery = new Platform.Data.Doublets.Link(index: any, source: any, target: any);
+
+ decoratedLinks.Each(linkQuery, link =>
+ {
+ var formattedLink = decoratedLinks.Format(link);
+ results.Add(formattedLink); // Note: Namify is not included in tests for simplicity
+ return decoratedLinks.Constants.Continue;
+ });
+ }
+
+ return JsonSerializer.Serialize(new
+ {
+ query,
+ changes = changesList.Select(change => new
+ {
+ before = change.Before.IsNull() ? null : decoratedLinks.Format(change.Before),
+ after = change.After.IsNull() ? null : decoratedLinks.Format(change.After)
+ }),
+ links = results
+ });
+ }
+ catch (Exception ex)
+ {
+ return JsonSerializer.Serialize(new { error = ex.Message });
+ }
+ });
+ }
+ }
+
+ // Helper class for testing WebSocket server
+ public class TestWebSocketServer : IDisposable
+ {
+ private readonly WebApplication _app;
+ private Task? _serverTask;
+ private readonly CancellationTokenSource _cancellationTokenSource = new();
+
+ public int Port { get; private set; }
+
+ public TestWebSocketServer(WebApplication app)
+ {
+ _app = app;
+ Port = GetAvailablePort();
+ }
+
+ public async Task StartAsync()
+ {
+ _app.Urls.Add($"http://localhost:{Port}");
+ _serverTask = _app.RunAsync(_cancellationTokenSource.Token);
+ await Task.Delay(500); // Wait for server to start
+ }
+
+ private static int GetAvailablePort()
+ {
+ using var socket = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Any, 0);
+ socket.Start();
+ var port = ((System.Net.IPEndPoint)socket.LocalEndpoint).Port;
+ socket.Stop();
+ return port;
+ }
+
+ public void Dispose()
+ {
+ _cancellationTokenSource.Cancel();
+ _serverTask?.Wait(TimeSpan.FromSeconds(5));
+ _app.DisposeAsync().AsTask().Wait();
+ _cancellationTokenSource.Dispose();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj b/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj
index 1d8f9ab..49087f8 100644
--- a/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj
+++ b/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj
@@ -15,7 +15,7 @@
link-foundation
A CLI tool for links manipulation.
clink
- 2.2.2
+ 2.3.0
Unlicense
https://github.com/link-foundation/link-cli
@@ -28,6 +28,10 @@
+
+
+
+
diff --git a/Foundation.Data.Doublets.Cli/Program.cs b/Foundation.Data.Doublets.Cli/Program.cs
index 1f9bfed..9b406fe 100644
--- a/Foundation.Data.Doublets.Cli/Program.cs
+++ b/Foundation.Data.Doublets.Cli/Program.cs
@@ -1,8 +1,16 @@
using System.CommandLine;
+using System.CommandLine.Invocation;
using Platform.Data;
using Platform.Data.Doublets;
using Platform.Data.Doublets.Memory.United.Generic;
using Platform.Protocols.Lino;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
using static Foundation.Data.Doublets.Cli.ChangesSimplifier;
using DoubletLink = Platform.Data.Doublets.Link;
@@ -70,6 +78,19 @@
afterOption.AddAlias("--links");
afterOption.AddAlias("-a");
+var serverOption = new Option(
+ name: "--server",
+ description: "Start server listening on a port",
+ getDefaultValue: () => false
+);
+
+var portOption = new Option(
+ name: "--port",
+ description: "Port to listen on when in server mode",
+ getDefaultValue: () => 8080
+);
+portOption.AddAlias("-p");
+
var rootCommand = new RootCommand("LiNo CLI Tool for managing links data store")
{
dbOption,
@@ -79,76 +100,93 @@
structureOption,
beforeOption,
changesOption,
- afterOption
+ afterOption,
+ serverOption,
+ portOption
};
-rootCommand.SetHandler(
- (string db, string queryOptionValue, string queryArgumentValue, bool trace, uint? structure, bool before, bool changes, bool after) =>
+rootCommand.SetHandler(async (context) =>
+{
+ var db = context.ParseResult.GetValueForOption(dbOption)!;
+ var queryOptionValue = context.ParseResult.GetValueForOption(queryOption);
+ var queryArgumentValue = context.ParseResult.GetValueForArgument(queryArgument);
+ var trace = context.ParseResult.GetValueForOption(traceOption);
+ var structure = context.ParseResult.GetValueForOption(structureOption);
+ var before = context.ParseResult.GetValueForOption(beforeOption);
+ var changes = context.ParseResult.GetValueForOption(changesOption);
+ var after = context.ParseResult.GetValueForOption(afterOption);
+ var server = context.ParseResult.GetValueForOption(serverOption);
+ var port = context.ParseResult.GetValueForOption(portOption);
+
+ // Handle server mode
+ if (server)
{
- var decoratedLinks = new NamedLinksDecorator(db, trace);
+ await StartServer(db, port, trace);
+ return;
+ }
+
+ var decoratedLinks = new NamedLinksDecorator(db, trace);
- // If --structure is provided, handle it separately
- if (structure.HasValue)
+ // If --structure is provided, handle it separately
+ if (structure.HasValue)
+ {
+ var linkId = structure.Value;
+ try
{
- var linkId = structure.Value;
- try
- {
- var structureFormatted = decoratedLinks.FormatStructure(linkId, link => decoratedLinks.IsFullPoint(linkId), true, true);
- Console.WriteLine(Namify(decoratedLinks, structureFormatted));
- }
- catch (Exception ex)
- {
- Console.Error.WriteLine($"Error formatting structure for link ID {linkId}: {ex.Message}");
- Environment.Exit(1);
- }
- return; // Exit after handling --structure
+ var structureFormatted = decoratedLinks.FormatStructure(linkId, link => decoratedLinks.IsFullPoint(linkId), true, true);
+ Console.WriteLine(Namify(decoratedLinks, structureFormatted));
}
-
- if (before)
+ catch (Exception ex)
{
- PrintAllLinks(decoratedLinks);
+ Console.Error.WriteLine($"Error formatting structure for link ID {linkId}: {ex.Message}");
+ context.ExitCode = 1;
+ return;
}
+ return; // Exit after handling --structure
+ }
- var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue;
+ if (before)
+ {
+ PrintAllLinks(decoratedLinks);
+ }
- var changesList = new List<(DoubletLink Before, DoubletLink After)>();
+ var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue;
- if (!string.IsNullOrWhiteSpace(effectiveQuery))
- {
- var options = new QueryProcessor.Options
- {
- Query = effectiveQuery,
- Trace = trace,
- ChangesHandler = (beforeLink, afterLink) =>
- {
- changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink)));
- return decoratedLinks.Constants.Continue;
- }
- };
+ var changesList = new List<(DoubletLink Before, DoubletLink After)>();
- QueryProcessor.ProcessQuery(decoratedLinks, options);
- }
-
- if (changes && changesList.Any())
+ if (!string.IsNullOrWhiteSpace(effectiveQuery))
+ {
+ var options = new QueryProcessor.Options
{
- // Simplify the collected changes
- var simplifiedChanges = SimplifyChanges(changesList);
-
- // Print the simplified changes
- foreach (var (linkBefore, linkAfter) in simplifiedChanges)
+ Query = effectiveQuery,
+ Trace = trace,
+ ChangesHandler = (beforeLink, afterLink) =>
{
- PrintChange(decoratedLinks, linkBefore, linkAfter);
+ changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink)));
+ return decoratedLinks.Constants.Continue;
}
- }
+ };
+
+ QueryProcessor.ProcessQuery(decoratedLinks, options);
+ }
+
+ if (changes && changesList.Any())
+ {
+ // Simplify the collected changes
+ var simplifiedChanges = SimplifyChanges(changesList);
- if (after)
+ // Print the simplified changes
+ foreach (var (linkBefore, linkAfter) in simplifiedChanges)
{
- PrintAllLinks(decoratedLinks);
+ PrintChange(decoratedLinks, linkBefore, linkAfter);
}
- },
- // Explicitly specify the type parameters
- dbOption, queryOption, queryArgument, traceOption, structureOption, beforeOption, changesOption, afterOption
-);
+ }
+
+ if (after)
+ {
+ PrintAllLinks(decoratedLinks);
+ }
+});
await rootCommand.InvokeAsync(args);
@@ -189,4 +227,147 @@ static void PrintChange(NamedLinksDecorator links, DoubletLink linkBefore,
var afterText = linkAfter.IsNull() ? "" : links.Format(linkAfter);
var formattedChange = $"({beforeText}) ({afterText})";
Console.WriteLine(Namify(links, formattedChange));
+}
+
+static async Task StartServer(string db, int port, bool trace)
+{
+ var builder = WebApplication.CreateSlimBuilder();
+
+ builder.Services.AddSingleton(provider => new NamedLinksDecorator(db, trace));
+
+ var app = builder.Build();
+
+ app.UseWebSockets();
+
+ app.Map("/ws", HandleWebSocketEndpoint);
+
+ Console.WriteLine($"LiNo WebSocket server started on ws://localhost:{port}/ws");
+ Console.WriteLine("Press Ctrl+C to stop the server");
+
+ await app.RunAsync($"http://localhost:{port}");
+}
+
+static async Task HandleWebSocketEndpoint(HttpContext context)
+{
+ if (!context.WebSockets.IsWebSocketRequest)
+ {
+ context.Response.StatusCode = 400;
+ return;
+ }
+
+ var decoratedLinks = context.RequestServices.GetRequiredService>();
+ using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
+ await HandleWebSocketConnection(webSocket, decoratedLinks);
+}
+
+static async Task HandleWebSocketConnection(WebSocket webSocket, NamedLinksDecorator decoratedLinks)
+{
+ var buffer = new byte[1024 * 4];
+
+ while (webSocket.State == WebSocketState.Open)
+ {
+ try
+ {
+ var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None);
+ break;
+ }
+
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ var query = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ var response = await ProcessLinoQuery(query, decoratedLinks);
+
+ var responseBytes = Encoding.UTF8.GetBytes(response);
+ await webSocket.SendAsync(
+ new ArraySegment(responseBytes),
+ WebSocketMessageType.Text,
+ true,
+ CancellationToken.None
+ );
+ }
+ }
+ catch (WebSocketException ex)
+ {
+ Console.WriteLine($"WebSocket error: {ex.Message}");
+ break;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error processing request: {ex.Message}");
+ var errorResponse = JsonSerializer.Serialize(new { error = ex.Message });
+ var errorBytes = Encoding.UTF8.GetBytes(errorResponse);
+
+ try
+ {
+ await webSocket.SendAsync(
+ new ArraySegment(errorBytes),
+ WebSocketMessageType.Text,
+ true,
+ CancellationToken.None
+ );
+ }
+ catch (WebSocketException)
+ {
+ break;
+ }
+ }
+ }
+}
+
+static async Task ProcessLinoQuery(string query, NamedLinksDecorator decoratedLinks)
+{
+ return await Task.Run(() =>
+ {
+ try
+ {
+ var changesList = new List<(DoubletLink Before, DoubletLink After)>();
+ var results = new List();
+
+ if (!string.IsNullOrWhiteSpace(query))
+ {
+ var options = new QueryProcessor.Options
+ {
+ Query = query,
+ Trace = false,
+ ChangesHandler = (beforeLink, afterLink) =>
+ {
+ changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink)));
+ return decoratedLinks.Constants.Continue;
+ }
+ };
+
+ QueryProcessor.ProcessQuery(decoratedLinks, options);
+
+ // Collect current state of all links
+ var any = decoratedLinks.Constants.Any;
+ var linkQuery = new DoubletLink(index: any, source: any, target: any);
+
+ decoratedLinks.Each(linkQuery, link =>
+ {
+ var formattedLink = decoratedLinks.Format(link);
+ results.Add(Namify(decoratedLinks, formattedLink));
+ return decoratedLinks.Constants.Continue;
+ });
+ }
+
+ return JsonSerializer.Serialize(new
+ {
+ query,
+ changes = changesList.Select(change => new
+ {
+ before = change.Before.IsNull() ? null : decoratedLinks.Format(change.Before),
+ after = change.After.IsNull() ? null : decoratedLinks.Format(change.After)
+ }),
+ links = results
+ });
+ }
+ catch (Exception ex)
+ {
+ return JsonSerializer.Serialize(new { error = ex.Message });
+ }
+ });
}
\ No newline at end of file
diff --git a/README.md b/README.md
index 9399258..9ba4fc9 100644
--- a/README.md
+++ b/README.md
@@ -227,6 +227,81 @@ clink '((1: 2 1) (2: 1 2)) ()' --changes --after
| `--before` | bool | `false` | `-b` | Print the state of the database before applying changes |
| `--changes` | bool | `false` | `-c` | Print the changes applied by the query |
| `--after` | bool | `false` | `--links`, `-a` | Print the state of the database after applying changes |
+| `--server` | bool | `false` | _None_ | Start server listening on a port |
+| `--port` | int | `8080` | `-p` | Port to listen on when in server mode |
+
+## Server mode (LiNo API)
+
+The CLI can be run in server mode, providing a WebSocket API for remote LiNo query execution:
+
+```bash
+clink --server --port 8080
+```
+
+This starts a WebSocket server that listens for LiNo queries and returns structured JSON responses. The server endpoint is available at `ws://localhost:PORT/ws`.
+
+### WebSocket API
+
+**Send:** LiNo query as plain text
+```
+() ((1 1))
+```
+
+**Receive:** JSON response with query results
+```json
+{
+ "query": "() ((1 1))",
+ "changes": [
+ {
+ "before": "",
+ "after": "(1: 1 1)"
+ }
+ ],
+ "links": [
+ "(1: 1 1)"
+ ]
+}
+```
+
+The response includes:
+- `query`: The original query that was processed
+- `changes`: Array of before/after pairs showing what changed
+- `links`: Current state of all links in the database
+
+### Server examples
+
+Start server on default port (8080):
+```bash
+clink --server
+```
+
+Start server on custom port:
+```bash
+clink --server --port 3000
+```
+
+Start server with custom database:
+```bash
+clink --server --db mydata.links --port 8080
+```
+
+### Client example (JavaScript)
+
+```javascript
+const ws = new WebSocket('ws://localhost:8080/ws');
+
+ws.onopen = function() {
+ // Send a LiNo query to create a link
+ ws.send('() ((1 1))');
+};
+
+ws.onmessage = function(event) {
+ const response = JSON.parse(event.data);
+ console.log('Query:', response.query);
+ console.log('Changes:', response.changes);
+ console.log('All links:', response.links);
+};
+```
## For developers and debugging
@@ -243,6 +318,19 @@ cd Foundation.Data.Doublets.Cli
dotnet run -- '(((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1)))' --changes --after
```
+### Run server mode from root
+
+```bash
+dotnet run --project Foundation.Data.Doublets.Cli -- --server --port 8080
+```
+
+### Run server mode from folder
+
+```bash
+cd Foundation.Data.Doublets.Cli
+dotnet run -- --server --port 8080
+```
+
### Complete examples:
```bash
diff --git a/examples/websocket_client_test.cs b/examples/websocket_client_test.cs
new file mode 100644
index 0000000..f3919b2
--- /dev/null
+++ b/examples/websocket_client_test.cs
@@ -0,0 +1,36 @@
+using System.Net.WebSockets;
+using System.Text;
+
+Console.WriteLine("Testing LiNo WebSocket server...");
+
+using var client = new ClientWebSocket();
+
+try
+{
+ await client.ConnectAsync(new Uri("ws://localhost:8081/ws"), CancellationToken.None);
+ Console.WriteLine("Connected to WebSocket server");
+
+ // Test query: Create a link
+ var testQuery = "() ((1 1))";
+ var queryBytes = Encoding.UTF8.GetBytes(testQuery);
+
+ await client.SendAsync(new ArraySegment(queryBytes), WebSocketMessageType.Text, true, CancellationToken.None);
+ Console.WriteLine($"Sent query: {testQuery}");
+
+ // Receive response
+ var buffer = new byte[1024 * 4];
+ var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None);
+
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ var response = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ Console.WriteLine($"Received response: {response}");
+ }
+
+ await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "Test completed", CancellationToken.None);
+ Console.WriteLine("WebSocket test completed successfully");
+}
+catch (Exception ex)
+{
+ Console.WriteLine($"Error: {ex.Message}");
+}
\ No newline at end of file