diff --git a/README.md b/README.md index c8c6098..218eeb4 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,29 @@ A short version of read operation will also work: clink '((($i:)) (($i:)))' --changes ``` +## Export database as LiNo + +Use `--out` to write the complete database to a `.lino` file after the query is processed. The older `--lino-output` option is also accepted. + +```bash +clink '() ((child: father mother))' --out database.lino +``` + +`database.lino`: + +```lino +(father: father father) +(mother: mother mother) +(child: father mother) +``` + +When links do not have names, exported references are plain link numbers: + +```lino +(1: 1 1) +(2: 1 2) +``` + ## Update single link Update link with index 1 and source 1 and target 1, changing target to 2. @@ -282,44 +305,45 @@ 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 | +| `--out` | string | _None_ | `--lino-output` | Write the complete database as a LiNo file | ## For developers and debugging ### Execute from root ```bash -dotnet run --project Foundation.Data.Doublets.Cli -- '(((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1)))' --changes --after +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- '(((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1)))' --changes --after ``` ### Execute from folder ```bash -cd Foundation.Data.Doublets.Cli +cd csharp/Foundation.Data.Doublets.Cli dotnet run -- '(((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1)))' --changes --after ``` ### Complete examples: ```bash -dotnet run --project Foundation.Data.Doublets.Cli -- '() ((1 1) (2 2))' --changes --after +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- '() ((1 1) (2 2))' --changes --after -dotnet run --project Foundation.Data.Doublets.Cli -- '((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1))' --changes --after +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- '((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1))' --changes --after -dotnet run --project Foundation.Data.Doublets.Cli -- '((1 2) (2 1)) ()' --changes --after +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- '((1 2) (2 1)) ()' --changes --after ``` ```bash -dotnet run --project Foundation.Data.Doublets.Cli -- '() ((1 2) (2 1))' --changes --after +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- '() ((1 2) (2 1))' --changes --after -dotnet run --project Foundation.Data.Doublets.Cli -- '((($index: $source $target)) (($index: $target $source)))' --changes --after +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- '((($index: $source $target)) (($index: $target $source)))' --changes --after -dotnet run --project Foundation.Data.Doublets.Cli -- '((1: 2 1) (2: 1 2)) ()' --changes --after +dotnet run --project csharp/Foundation.Data.Doublets.Cli -- '((1: 2 1) (2: 1 2)) ()' --changes --after ``` ### Publish next version: ```bash -VERSION=$(awk -F'[<>]' '// {print $3}' Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj) && git tag "v$VERSION" && git push origin "v$VERSION" +VERSION=$(awk -F'[<>]' '// {print $3}' csharp/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj) && git tag "v$VERSION" && git push origin "v$VERSION" ``` ## Running a Specific Test with Detailed Output diff --git a/csharp/.changeset/add-lino-output-export.md b/csharp/.changeset/add-lino-output-export.md new file mode 100644 index 0000000..c4fab40 --- /dev/null +++ b/csharp/.changeset/add-lino-output-export.md @@ -0,0 +1,5 @@ +--- +'Foundation.Data.Doublets.Cli': minor +--- + +Added `--out`/`--lino-output` database export support that writes the complete links database as LiNo with named references when available. diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs index 5662198..a0bcfb1 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/ChangesSimplifier.cs @@ -14,13 +14,13 @@ public void SimplifyChanges_SpecificExample_RemovesIntermediateStates() { // (1: 2 1) ↦ (1: 0 0) (new Link(index: 1, source: 2, target: 1), new Link(index: 1, source: 0, target: 0)), - + // (2: 1 2) ↦ (2: 0 0) (new Link(index: 2, source: 1, target: 2), new Link(index: 2, source: 0, target: 0)), - + // (2: 0 0) ↦ (0: 0 0) (new Link(index: 2, source: 0, target: 0), new Link(index: 0, source: 0, target: 0)), - + // (1: 0 0) ↦ (0: 0 0) (new Link(index: 1, source: 0, target: 0), new Link(index: 0, source: 0, target: 0)) }; @@ -61,13 +61,13 @@ public void SimplifyChanges_MultipleChainsFromSameBefore_RemovesIntermediateStat { // (0: 0 0) ↦ (1: 0 0) (new Link(index: 0, source: 0, target: 0), new Link(index: 1, source: 0, target: 0)), - + // (1: 0 0) ↦ (1: 1 2) (new Link(index: 1, source: 0, target: 0), new Link(index: 1, source: 1, target: 2)), - + // (0: 0 0) ↦ (2: 0 0) (new Link(index: 0, source: 0, target: 0), new Link(index: 2, source: 0, target: 0)), - + // (2: 0 0) ↦ (2: 2 1) (new Link(index: 2, source: 0, target: 0), new Link(index: 2, source: 2, target: 1)) }; @@ -337,10 +337,10 @@ public void SimplifyChanges_Issue26_UpdateOperationSimplification() { // Step 1: Link (1: 1 2) is first deleted (becomes null/empty) (new Link(index: 1, source: 1, target: 2), new Link(index: 0, source: 0, target: 0)), - + // Step 2: New link (1: 2 1) is created (swapped source and target) (new Link(index: 0, source: 0, target: 0), new Link(index: 1, source: 2, target: 1)), - + // Step 3: Link (2: 2 1) is directly updated to (2: 1 2) (new Link(index: 2, source: 2, target: 1), new Link(index: 2, source: 1, target: 2)), }; diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs new file mode 100644 index 0000000..4ea7996 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs @@ -0,0 +1,138 @@ +using Foundation.Data.Doublets.Cli; +using Platform.Data.Doublets; + +namespace Foundation.Data.Doublets.Cli.Tests; + +public class LinoDatabaseOutputTests +{ + [Fact] + public void FormatDatabase_UsesNumberedReferences_WhenLinksHaveNoNames() + { + WithNamedLinks(links => + { + links.GetOrCreate(1u, 1u); + links.GetOrCreate(1u, 2u); + + var lines = LinoDatabaseOutput.FormatDatabase(links); + + Assert.Equal(new[] { "(1: 1 1)", "(2: 1 2)" }, lines); + }); + } + + [Fact] + public void FormatDatabase_UsesNamesForIndexesSourcesAndTargets_WhenNamesExist() + { + WithNamedLinks(links => + { + var father = links.GetOrCreate(1u, 1u); + links.SetName(father, "father"); + var mother = links.GetOrCreate(2u, 2u); + links.SetName(mother, "mother"); + var child = links.GetOrCreate(father, mother); + links.SetName(child, "child"); + + var lines = LinoDatabaseOutput.FormatDatabase(links); + + Assert.Equal(new[] + { + "(father: father father)", + "(mother: mother mother)", + "(child: father mother)" + }, lines); + }); + } + + [Fact] + public void FormatDatabase_EscapesNamesThatNeedQuoting() + { + WithNamedLinks(links => + { + var source = links.GetOrCreate(1u, 1u); + links.SetName(source, "source name"); + var target = links.GetOrCreate(2u, 2u); + links.SetName(target, "target:ref"); + var child = links.GetOrCreate(source, target); + links.SetName(child, "child(ref)"); + + var lines = LinoDatabaseOutput.FormatDatabase(links); + + Assert.Equal(new[] + { + "('source name': 'source name' 'source name')", + "('target:ref': 'target:ref' 'target:ref')", + "('child(ref)': 'source name' 'target:ref')" + }, lines); + }); + } + + [Fact] + public void FormatDatabase_SelectsQuoteStyleForNamesContainingQuotes() + { + WithNamedLinks(links => + { + var singleQuote = links.GetOrCreate(1u, 1u); + links.SetName(singleQuote, "single'quote"); + var doubleQuote = links.GetOrCreate(2u, 2u); + links.SetName(doubleQuote, "double\"quote"); + var bothQuotes = links.GetOrCreate(singleQuote, doubleQuote); + links.SetName(bothQuotes, "both'\"quote"); + + var lines = LinoDatabaseOutput.FormatDatabase(links); + + Assert.Equal(new[] + { + "(\"single'quote\": \"single'quote\" \"single'quote\")", + "('double\"quote': 'double\"quote' 'double\"quote')", + "('both\\'\"quote': \"single'quote\" 'double\"quote')" + }, lines); + }); + } + + [Fact] + public void WriteToFile_WritesCompleteDatabaseAsLinoLines() + { + WithNamedLinks(links => + { + links.GetOrCreate(1u, 1u); + links.GetOrCreate(2u, 2u); + + var outputPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.lino"); + try + { + LinoDatabaseOutput.WriteToFile(links, outputPath); + + Assert.Equal(new[] { "(1: 1 1)", "(2: 2 2)" }, File.ReadAllLines(outputPath)); + } + finally + { + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + } + }); + } + + private static void WithNamedLinks(Action> test) + { + var dbPath = Path.GetTempFileName(); + var namesDbPath = NamedLinksDecorator.MakeNamesDatabaseFilename(dbPath); + + try + { + var links = new NamedLinksDecorator(dbPath, false); + test(links); + } + finally + { + if (File.Exists(dbPath)) + { + File.Delete(dbPath); + } + if (File.Exists(namesDbPath)) + { + File.Delete(namesDbPath); + } + } + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli/ChangesSimplifier.cs b/csharp/Foundation.Data.Doublets.Cli/ChangesSimplifier.cs index 68cb8f9..9786183 100644 --- a/csharp/Foundation.Data.Doublets.Cli/ChangesSimplifier.cs +++ b/csharp/Foundation.Data.Doublets.Cli/ChangesSimplifier.cs @@ -132,7 +132,7 @@ public static class ChangesSimplifier /// Removes problematic duplicate before states that lead to simplification issues. /// This fixes Issue #26 where multiple transformations from the same before state /// to conflicting after states (including null states) would cause the simplifier to fail. - /// + /// /// The key insight: If we have multiple transitions from the same before state, /// and one of them is to a "null" state (0: 0 0), we should prefer the non-null transition /// as it represents the actual final transformation. @@ -144,13 +144,13 @@ public static class ChangesSimplifier { // Group changes by their before state var groupedChanges = changes.GroupBy(c => c.Before, LinkEqualityComparer.Instance); - + var result = new List<(Link Before, Link After)>(); - + foreach (var group in groupedChanges) { var changesForThisBefore = group.ToList(); - + if (changesForThisBefore.Count == 1) { // No duplicates, keep as is @@ -160,11 +160,11 @@ public static class ChangesSimplifier { // Multiple changes from the same before state // Check if any of them is to a null state (0: 0 0) - var nullTransition = changesForThisBefore.FirstOrDefault(c => + var nullTransition = changesForThisBefore.FirstOrDefault(c => c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0); - var nonNullTransitions = changesForThisBefore.Where(c => + var nonNullTransitions = changesForThisBefore.Where(c => !(c.After.Index == 0 && c.After.Source == 0 && c.After.Target == 0)).ToList(); - + if (nullTransition != default && nonNullTransitions.Count > 0) { // Issue #26 scenario: We have both null and non-null transitions @@ -179,7 +179,7 @@ public static class ChangesSimplifier } } } - + return result; } diff --git a/csharp/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj b/csharp/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj index eedce14..b72f585 100644 --- a/csharp/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj +++ b/csharp/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj @@ -1,34 +1,34 @@ - - - - Exe - net8 - enable - enable - true - clink - README.md - Foundation.Data.Doublets.Cli - - - - link-foundation - A CLI tool for links manipulation. - clink - 2.2.2 - Unlicense - https://github.com/link-foundation/link-cli - - - - - - - - - - - - - - + + + + Exe + net8 + enable + enable + true + clink + README.md + Foundation.Data.Doublets.Cli + + + + link-foundation + A CLI tool for links manipulation. + clink + 2.2.2 + Unlicense + https://github.com/link-foundation/link-cli + + + + + + + + + + + + + + diff --git a/csharp/Foundation.Data.Doublets.Cli/LinoDatabaseOutput.cs b/csharp/Foundation.Data.Doublets.Cli/LinoDatabaseOutput.cs new file mode 100644 index 0000000..b0e08a2 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli/LinoDatabaseOutput.cs @@ -0,0 +1,104 @@ +using System.Text.RegularExpressions; +using Platform.Data; +using Platform.Data.Doublets; + +using DoubletLink = Platform.Data.Doublets.Link; + +namespace Foundation.Data.Doublets.Cli; + +public static class LinoDatabaseOutput +{ + private static readonly Regex NumberTokenRegex = new(@"(? FormatDatabase(NamedLinksDecorator links) + { + var any = links.Constants.Any; + var query = new DoubletLink(index: any, source: any, target: any); + + return links + .All(query) + .Select(link => new DoubletLink(link)) + .OrderBy(link => link.Index) + .Select(link => FormatLink(links, link)) + .ToList(); + } + + public static void WriteDatabase(NamedLinksDecorator links, TextWriter writer) + { + foreach (var line in FormatDatabase(links)) + { + writer.WriteLine(line); + } + } + + public static void WriteToFile(NamedLinksDecorator links, string path) + { + using var writer = new StreamWriter(path, append: false); + WriteDatabase(links, writer); + } + + public static string FormatLink(NamedLinksDecorator links, DoubletLink link) + { + return $"({FormatReference(links, link.Index)}: {FormatReference(links, link.Source)} {FormatReference(links, link.Target)})"; + } + + public static string FormatChange(NamedLinksDecorator links, DoubletLink linkBefore, DoubletLink linkAfter) + { + var beforeText = linkBefore.IsNull() ? "" : FormatLink(links, linkBefore); + var afterText = linkAfter.IsNull() ? "" : FormatLink(links, linkAfter); + return $"({beforeText}) ({afterText})"; + } + + public static string Namify(NamedLinksDecorator namedLinks, string linksNotation) + { + return NumberTokenRegex.Replace(linksNotation, match => + { + var numberLink = uint.Parse(match.Value); + var name = namedLinks.GetName(numberLink); + return name is null ? match.Value : EscapeReference(name); + }); + } + + private static string FormatReference(NamedLinksDecorator links, uint link) + { + var name = links.GetName(link); + return name is null ? link.ToString() : EscapeReference(name); + } + + private static string EscapeReference(string reference) + { + if (string.IsNullOrEmpty(reference) || string.IsNullOrWhiteSpace(reference)) + { + return string.Empty; + } + + var hasSingleQuote = reference.Contains('\''); + var hasDoubleQuote = reference.Contains('"'); + var needsQuoting = reference.Contains(':') + || reference.Contains('(') + || reference.Contains(')') + || reference.Contains(' ') + || reference.Contains('\t') + || reference.Contains('\n') + || reference.Contains('\r') + || hasSingleQuote + || hasDoubleQuote; + + if (hasSingleQuote && hasDoubleQuote) + { + return $"'{reference.Replace("'", "\\'")}'"; + } + + if (hasDoubleQuote) + { + return $"'{reference}'"; + } + + if (hasSingleQuote) + { + return $"\"{reference}\""; + } + + return needsQuoting ? $"'{reference}'" : reference; + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli/Program.cs b/csharp/Foundation.Data.Doublets.Cli/Program.cs index 2021dc3..6c5c73d 100644 --- a/csharp/Foundation.Data.Doublets.Cli/Program.cs +++ b/csharp/Foundation.Data.Doublets.Cli/Program.cs @@ -1,210 +1,215 @@ -using System.CommandLine; -using Platform.Data; -using Platform.Data.Doublets; -using Platform.Data.Doublets.Memory.United.Generic; -using Platform.Protocols.Lino; - -using static Foundation.Data.Doublets.Cli.ChangesSimplifier; -using DoubletLink = Platform.Data.Doublets.Link; -using QueryProcessor = Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor; -using Foundation.Data.Doublets.Cli; -using System.Text.RegularExpressions; - -const string defaultDatabaseFilename = "db.links"; - -var dbOption = new Option( - name: "--db", - description: "Path to the links database file", - getDefaultValue: () => defaultDatabaseFilename -); -dbOption.AddAlias("--data-source"); -dbOption.AddAlias("--data"); -dbOption.AddAlias("-d"); - -var queryOption = new Option( - name: "--query", - description: "LiNo query for CRUD operation" -); -queryOption.AddAlias("--apply"); -queryOption.AddAlias("--do"); -queryOption.AddAlias("-q"); - -var queryArgument = new Argument( - name: "query", - description: "LiNo query for CRUD operation" -); -queryArgument.Arity = ArgumentArity.ZeroOrOne; - -var traceOption = new Option( - name: "--trace", - description: "Enable trace (verbose output)", - getDefaultValue: () => false -); -traceOption.AddAlias("-t"); - -var structureOption = new Option( - name: "--structure", - description: "ID of the link to format its structure" -); -structureOption.AddAlias("-s"); - -var beforeOption = new Option( - name: "--before", - description: "Print the state of the database before applying changes", - getDefaultValue: () => false -); -beforeOption.AddAlias("-b"); - -var changesOption = new Option( - name: "--changes", - description: "Print the changes applied by the query", - getDefaultValue: () => false -); -changesOption.AddAlias("-c"); - -var afterOption = new Option( - name: "--after", - description: "Print the state of the database after applying changes", - getDefaultValue: () => false -); -afterOption.AddAlias("--links"); -afterOption.AddAlias("-a"); - -var rootCommand = new RootCommand("LiNo CLI Tool for managing links data store") -{ - dbOption, - queryOption, - queryArgument, - traceOption, - structureOption, - beforeOption, - changesOption, - afterOption -}; - -rootCommand.SetHandler( - (string db, string queryOptionValue, string queryArgumentValue, bool trace, uint? structure, bool before, bool changes, bool after) => - { - var decoratedLinks = new NamedLinksDecorator(db, trace); - - // If --structure is provided, handle it separately - if (structure.HasValue) - { - 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 - } - - if (before) - { - PrintAllLinks(decoratedLinks); - } - - var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; - - var changesList = new List<(DoubletLink Before, DoubletLink After)>(); - - 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; - } - }; - - QueryProcessor.ProcessQuery(decoratedLinks, options); - } - - if (changes && changesList.Any()) - { - // Debug: Print raw changes before simplification (if trace is enabled) - if (trace) - { - Console.WriteLine("[DEBUG] Raw changes before simplification:"); - for (int i = 0; i < changesList.Count; i++) - { - var (beforeLink, afterLink) = changesList[i]; - Console.WriteLine($"[DEBUG] {i + 1}. ({beforeLink.Index}: {beforeLink.Source} {beforeLink.Target}) -> ({afterLink.Index}: {afterLink.Source} {afterLink.Target})"); - } - Console.WriteLine($"[DEBUG] Total raw changes: {changesList.Count}"); - } - - // Simplify the collected changes - var simplifiedChanges = SimplifyChanges(changesList); - - // Debug: Print simplified changes count (if trace is enabled) - if (trace) - { - Console.WriteLine($"[DEBUG] Simplified changes count: {simplifiedChanges.Count()}"); - } - - // Print the simplified changes - foreach (var (linkBefore, linkAfter) in simplifiedChanges) - { - PrintChange(decoratedLinks, linkBefore, linkAfter); - } - } - - if (after) - { - PrintAllLinks(decoratedLinks); - } - }, - // Explicitly specify the type parameters - dbOption, queryOption, queryArgument, traceOption, structureOption, beforeOption, changesOption, afterOption -); - -await rootCommand.InvokeAsync(args); - -static string Namify(NamedLinksDecorator namedLinks, string linksNotation) -{ - var numberGlobalRegex = new Regex(@"\d+"); - var matches = numberGlobalRegex.Matches(linksNotation); - var newLinksNotation = linksNotation; - foreach (Match match in matches) - { - var number = match.Value; - var numberLink = uint.Parse(number); - var name = namedLinks.GetName(numberLink); - if (name != null) - { - newLinksNotation = newLinksNotation.Replace(number, name); - } - } - return newLinksNotation; -} - -static void PrintAllLinks(NamedLinksDecorator links) -{ - var any = links.Constants.Any; - var query = new DoubletLink(index: any, source: any, target: any); - - links.Each(query, link => - { - var formattedLink = links.Format(link); - Console.WriteLine(Namify(links, formattedLink)); - return links.Constants.Continue; - }); -} - -static void PrintChange(NamedLinksDecorator links, DoubletLink linkBefore, DoubletLink linkAfter) -{ - var beforeText = linkBefore.IsNull() ? "" : links.Format(linkBefore); - var afterText = linkAfter.IsNull() ? "" : links.Format(linkAfter); - var formattedChange = $"({beforeText}) ({afterText})"; - Console.WriteLine(Namify(links, formattedChange)); -} \ No newline at end of file +using System.CommandLine; +using System.CommandLine.Invocation; +using Foundation.Data.Doublets.Cli; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Protocols.Lino; + +using static Foundation.Data.Doublets.Cli.ChangesSimplifier; +using DoubletLink = Platform.Data.Doublets.Link; +using QueryProcessor = Foundation.Data.Doublets.Cli.AdvancedMixedQueryProcessor; + +const string defaultDatabaseFilename = "db.links"; + +var dbOption = new Option( + name: "--db", + description: "Path to the links database file", + getDefaultValue: () => defaultDatabaseFilename +); +dbOption.AddAlias("--data-source"); +dbOption.AddAlias("--data"); +dbOption.AddAlias("-d"); + +var queryOption = new Option( + name: "--query", + description: "LiNo query for CRUD operation" +); +queryOption.AddAlias("--apply"); +queryOption.AddAlias("--do"); +queryOption.AddAlias("-q"); + +var queryArgument = new Argument( + name: "query", + description: "LiNo query for CRUD operation" +); +queryArgument.Arity = ArgumentArity.ZeroOrOne; + +var traceOption = new Option( + name: "--trace", + description: "Enable trace (verbose output)", + getDefaultValue: () => false +); +traceOption.AddAlias("-t"); + +var structureOption = new Option( + name: "--structure", + description: "ID of the link to format its structure" +); +structureOption.AddAlias("-s"); + +var beforeOption = new Option( + name: "--before", + description: "Print the state of the database before applying changes", + getDefaultValue: () => false +); +beforeOption.AddAlias("-b"); + +var changesOption = new Option( + name: "--changes", + description: "Print the changes applied by the query", + getDefaultValue: () => false +); +changesOption.AddAlias("-c"); + +var afterOption = new Option( + name: "--after", + description: "Print the state of the database after applying changes", + getDefaultValue: () => false +); +afterOption.AddAlias("--links"); +afterOption.AddAlias("-a"); + +var outputOption = new Option( + name: "--out", + description: "Path to write the complete database as a LiNo file" +); +outputOption.AddAlias("--lino-output"); + +var rootCommand = new RootCommand("LiNo CLI Tool for managing links data store") +{ + dbOption, + queryOption, + queryArgument, + traceOption, + structureOption, + beforeOption, + changesOption, + afterOption, + outputOption +}; + +rootCommand.SetHandler( + (InvocationContext 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 outputPath = context.ParseResult.GetValueForOption(outputOption); + + var decoratedLinks = new NamedLinksDecorator(db, trace); + + if (structure.HasValue) + { + var linkId = structure.Value; + try + { + var structureFormatted = decoratedLinks.FormatStructure(linkId, link => decoratedLinks.IsFullPoint(linkId), true, true); + Console.WriteLine(LinoDatabaseOutput.Namify(decoratedLinks, structureFormatted)); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error formatting structure for link ID {linkId}: {ex.Message}"); + context.ExitCode = 1; + return; + } + + TryWriteLinoOutput(decoratedLinks, outputPath, context); + return; + } + + if (before) + { + PrintAllLinks(decoratedLinks); + } + + var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; + + var changesList = new List<(DoubletLink Before, DoubletLink After)>(); + + 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; + } + }; + + QueryProcessor.ProcessQuery(decoratedLinks, options); + } + + if (changes && changesList.Any()) + { + if (trace) + { + Console.WriteLine("[DEBUG] Raw changes before simplification:"); + for (int i = 0; i < changesList.Count; i++) + { + var (beforeLink, afterLink) = changesList[i]; + Console.WriteLine($"[DEBUG] {i + 1}. ({beforeLink.Index}: {beforeLink.Source} {beforeLink.Target}) -> ({afterLink.Index}: {afterLink.Source} {afterLink.Target})"); + } + Console.WriteLine($"[DEBUG] Total raw changes: {changesList.Count}"); + } + + var simplifiedChanges = SimplifyChanges(changesList); + + if (trace) + { + Console.WriteLine($"[DEBUG] Simplified changes count: {simplifiedChanges.Count()}"); + } + + foreach (var (linkBefore, linkAfter) in simplifiedChanges) + { + PrintChange(decoratedLinks, linkBefore, linkAfter); + } + } + + if (after) + { + PrintAllLinks(decoratedLinks); + } + + TryWriteLinoOutput(decoratedLinks, outputPath, context); + } +); + +await rootCommand.InvokeAsync(args); + +static void PrintAllLinks(NamedLinksDecorator links) +{ + LinoDatabaseOutput.WriteDatabase(links, Console.Out); +} + +static void PrintChange(NamedLinksDecorator links, DoubletLink linkBefore, DoubletLink linkAfter) +{ + Console.WriteLine(LinoDatabaseOutput.FormatChange(links, linkBefore, linkAfter)); +} + +static bool TryWriteLinoOutput(NamedLinksDecorator links, string? outputPath, InvocationContext context) +{ + if (string.IsNullOrWhiteSpace(outputPath)) + { + return true; + } + + try + { + LinoDatabaseOutput.WriteToFile(links, outputPath); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException) + { + Console.Error.WriteLine($"Error writing LiNo output file '{outputPath}': {ex.Message}"); + context.ExitCode = 1; + return false; + } +} diff --git a/examples/debug_changes.cs b/examples/debug_changes.cs index a19c16d..958c1ba 100644 --- a/examples/debug_changes.cs +++ b/examples/debug_changes.cs @@ -7,7 +7,7 @@ { // This simulates what might be happening in the update operation // Let's say we have links being swapped: (1:1 2) -> (1:2 1) and (2:2 1) -> (2:1 2) - + // From the issue it looks like these might be the intermediate steps: (new Link(index: 1, source: 1, target: 2), new Link(index: 1, source: 2, target: 1)), (new Link(index: 2, source: 2, target: 1), new Link(index: 2, source: 1, target: 2)), diff --git a/examples/test_issue_scenario.cs b/examples/test_issue_scenario.cs index 01c4c23..2606fab 100644 --- a/examples/test_issue_scenario.cs +++ b/examples/test_issue_scenario.cs @@ -9,7 +9,7 @@ Console.WriteLine("=== Testing Issue #26 Scenario ==="); // This simulates what might be happening based on the issue output: -// ((1: 1 2)) () - Link (1: 1 2) is deleted +// ((1: 1 2)) () - Link (1: 1 2) is deleted // ((1: 1 2)) ((1: 2 1)) - Link (1: 1 2) becomes (1: 2 1) // ((2: 2 1)) ((2: 1 2)) - Link (2: 2 1) becomes (2: 1 2) @@ -17,10 +17,10 @@ { // First transformation: (1: 1 2) -> () (deletion) (new Link(index: 1, source: 1, target: 2), new Link(index: 0, source: 0, target: 0)), - + // Second transformation: () -> (1: 2 1) (creation) (new Link(index: 0, source: 0, target: 0), new Link(index: 1, source: 2, target: 1)), - + // Third transformation: (2: 2 1) -> (2: 1 2) (direct update) (new Link(index: 2, source: 2, target: 1), new Link(index: 2, source: 1, target: 2)), }; diff --git a/rust/changelog.d/20260430_063000_lino_output_export.md b/rust/changelog.d/20260430_063000_lino_output_export.md new file mode 100644 index 0000000..baceda9 --- /dev/null +++ b/rust/changelog.d/20260430_063000_lino_output_export.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Added `--out`/`--lino-output` database export support that writes the complete links database as LiNo with named references when available. diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 7ab8293..b2f8111 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -16,6 +16,7 @@ pub struct Cli { pub before: bool, pub changes: bool, pub after: bool, + pub lino_output: Option, } impl Default for Cli { @@ -29,6 +30,7 @@ impl Default for Cli { before: false, changes: false, after: false, + lino_output: None, } } } @@ -88,6 +90,10 @@ impl Cli { cli.after = parse_bool("--after", value)?; continue; } + if let Some(value) = inline_value(&arg, &["--out", "--lino-output"]) { + cli.lino_output = Some(value.to_string()); + continue; + } match arg.as_str() { "-h" | "--help" => return Ok(CliCommand::Help), @@ -114,6 +120,9 @@ impl Cli { "-a" | "--after" | "--links" => { cli.after = next_bool_value(&mut args, true)?; } + "--out" | "--lino-output" => { + cli.lino_output = Some(next_value(&mut args, &arg)?); + } "--" => { for value in args.by_ref() { set_positional_query(&mut cli, value)?; @@ -157,6 +166,8 @@ impl Cli { " Print the changes applied by the query\n", " -a, --after, --links\n", " Print the state of the database after applying changes\n", + " --out , --lino-output \n", + " Write the complete database as a LiNo file\n", " -h, --help\n", " Print help\n", " -V, --version\n", diff --git a/rust/src/link_storage.rs b/rust/src/link_storage.rs index c2736fd..189ea24 100644 --- a/rust/src/link_storage.rs +++ b/rust/src/link_storage.rs @@ -290,6 +290,44 @@ impl LinkStorage { format!("({} {} {})", index_str, source_str, target_str) } + /// Formats a link as LiNo suitable for database export. + pub fn format_lino(&self, link: &Link) -> String { + format!( + "({}: {} {})", + self.format_lino_reference(link.index), + self.format_lino_reference(link.source), + self.format_lino_reference(link.target) + ) + } + + /// Returns all database links as sorted LiNo lines. + pub fn lino_lines(&self) -> Vec { + let mut links: Vec<_> = self.all(); + links.sort_by_key(|l| l.index); + links + .into_iter() + .map(|link| self.format_lino(link)) + .collect() + } + + /// Writes the complete database as LiNo. + pub fn write_lino_output>(&self, path: P) -> Result<()> { + let path = path.as_ref(); + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(path) + .with_context(|| format!("Failed to create LiNo output: {}", path.display()))?; + + let mut writer = BufWriter::new(file); + for line in self.lino_lines() { + writeln!(writer, "{line}")?; + } + writer.flush()?; + Ok(()) + } + /// Formats the structure of a link pub fn format_structure(&self, id: u32) -> Result { if let Some(link) = self.get(id) { @@ -407,4 +445,47 @@ impl LinkStorage { pub fn is_trace_enabled(&self) -> bool { self.trace } + + fn format_lino_reference(&self, id: u32) -> String { + self.names + .get(&id) + .map(|name| escape_lino_reference(name)) + .unwrap_or_else(|| id.to_string()) + } +} + +fn escape_lino_reference(reference: &str) -> String { + if reference.is_empty() || reference.trim().is_empty() { + return String::new(); + } + + let has_single_quote = reference.contains('\''); + let has_double_quote = reference.contains('"'); + let needs_quoting = reference.contains(':') + || reference.contains('(') + || reference.contains(')') + || reference.contains(' ') + || reference.contains('\t') + || reference.contains('\n') + || reference.contains('\r') + || has_single_quote + || has_double_quote; + + if has_single_quote && has_double_quote { + return format!("'{}'", reference.replace('\'', "\\'")); + } + + if has_double_quote { + return format!("'{reference}'"); + } + + if has_single_quote { + return format!("\"{reference}\""); + } + + if needs_quoting { + return format!("'{reference}'"); + } + + reference.to_string() } diff --git a/rust/src/main.rs b/rust/src/main.rs index a49d00d..d3ef112 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -27,6 +27,9 @@ fn main() -> Result<()> { if let Some(link_id) = cli.structure { let structure_formatted = storage.format_structure(link_id)?; println!("{}", structure_formatted); + if let Some(output_path) = &cli.lino_output { + storage.write_lino_output(output_path)?; + } return Ok(()); } @@ -36,7 +39,7 @@ fn main() -> Result<()> { } // Get effective query (option takes precedence over positional argument) - let effective_query = cli.query.or(cli.query_arg); + let effective_query = cli.query.as_deref().or(cli.query_arg.as_deref()); // Collect changes let mut changes_list = Vec::new(); @@ -45,7 +48,7 @@ fn main() -> Result<()> { if let Some(query) = effective_query { if !query.is_empty() { let processor = QueryProcessor::new(cli.trace); - changes_list = processor.process_query(&mut storage, &query)?; + changes_list = processor.process_query(&mut storage, query)?; } } @@ -61,5 +64,9 @@ fn main() -> Result<()> { storage.print_all_links(); } + if let Some(output_path) = &cli.lino_output { + storage.write_lino_output(output_path)?; + } + Ok(()) } diff --git a/rust/tests/cli_arguments_tests.rs b/rust/tests/cli_arguments_tests.rs index 453223b..f65f0c2 100644 --- a/rust/tests/cli_arguments_tests.rs +++ b/rust/tests/cli_arguments_tests.rs @@ -18,6 +18,8 @@ fn parses_csharp_option_aliases_without_direct_clap_dependency() { "--apply", "(1 2)", "--links", + "--out", + "dump.lino", "-b", "-c", "-t", @@ -32,6 +34,7 @@ fn parses_csharp_option_aliases_without_direct_clap_dependency() { assert!(cli.changes); assert!(cli.trace); assert_eq!(cli.structure, Some(42)); + assert_eq!(cli.lino_output.as_deref(), Some("dump.lino")); } #[test] @@ -52,6 +55,7 @@ fn parses_inline_alias_values_and_boolean_values() { "--before=true", "--changes=on", "--after=0", + "--lino-output=links.lino", ]); assert_eq!(cli.db, "db.bin"); @@ -60,6 +64,7 @@ fn parses_inline_alias_values_and_boolean_values() { assert!(cli.before); assert!(cli.changes); assert!(!cli.after); + assert_eq!(cli.lino_output.as_deref(), Some("links.lino")); } #[test] diff --git a/rust/tests/link_storage_tests.rs b/rust/tests/link_storage_tests.rs index ca91343..4137752 100644 --- a/rust/tests/link_storage_tests.rs +++ b/rust/tests/link_storage_tests.rs @@ -117,3 +117,111 @@ fn test_storage_get_or_create() -> Result<()> { Ok(()) } + +#[test] +fn test_lino_lines_use_numbered_references_without_names() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + storage.create(1, 1); + storage.create(1, 2); + + assert_eq!(storage.lino_lines(), vec!["(1: 1 1)", "(2: 1 2)"]); + + Ok(()) +} + +#[test] +fn test_lino_lines_use_names_for_indexes_sources_and_targets() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let father = storage.get_or_create_named("father"); + let mother = storage.get_or_create_named("mother"); + let child = storage.create(father, mother); + storage.set_name(child, "child"); + + assert_eq!( + storage.lino_lines(), + vec![ + "(father: father father)", + "(mother: mother mother)", + "(child: father mother)" + ] + ); + + Ok(()) +} + +#[test] +fn test_lino_lines_escape_names_that_need_quoting() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let source = storage.create(1, 1); + storage.set_name(source, "source name"); + let target = storage.create(2, 2); + storage.set_name(target, "target:ref"); + let child = storage.create(source, target); + storage.set_name(child, "child(ref)"); + + assert_eq!( + storage.lino_lines(), + vec![ + "('source name': 'source name' 'source name')", + "('target:ref': 'target:ref' 'target:ref')", + "('child(ref)': 'source name' 'target:ref')" + ] + ); + + Ok(()) +} + +#[test] +fn test_lino_lines_select_quote_style_for_names_containing_quotes() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let single_quote = storage.create(1, 1); + storage.set_name(single_quote, "single'quote"); + let double_quote = storage.create(2, 2); + storage.set_name(double_quote, "double\"quote"); + let both_quotes = storage.create(single_quote, double_quote); + storage.set_name(both_quotes, "both'\"quote"); + + assert_eq!( + storage.lino_lines(), + vec![ + "(\"single'quote\": \"single'quote\" \"single'quote\")", + "('double\"quote': 'double\"quote' 'double\"quote')", + "('both\\'\"quote': \"single'quote\" 'double\"quote')" + ] + ); + + Ok(()) +} + +#[test] +fn test_write_lino_output_writes_complete_database() -> Result<()> { + let db_file = NamedTempFile::new()?; + let output_file = NamedTempFile::new()?; + let db_path = db_file.path().to_str().unwrap(); + let output_path = output_file.path(); + + let mut storage = LinkStorage::new(db_path, false)?; + storage.create(1, 1); + storage.create(2, 2); + + storage.write_lino_output(output_path)?; + + assert_eq!( + std::fs::read_to_string(output_path)?, + "(1: 1 1)\n(2: 2 2)\n" + ); + + Ok(()) +}