diff --git a/csharp/.changeset/add-lino-input-import.md b/csharp/.changeset/add-lino-input-import.md new file mode 100644 index 0000000..c836b27 --- /dev/null +++ b/csharp/.changeset/add-lino-input-import.md @@ -0,0 +1,5 @@ +--- +'Foundation.Data.Doublets.Cli': minor +--- + +Added `--in`/`--lino-input`/`--import` database import support for reading LiNo files into the links database with named references enabled by default. diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs index f9831ee..cc74dea 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs @@ -79,6 +79,39 @@ public async Task StructureOption_RendersLeftBranchWithIndexes() } } + [Fact] + public async Task ImportOption_ReadsNumberedLinoFile() + { + var tempDirectory = CreateTempDirectory(); + + try + { + var dbPath = Path.Combine(tempDirectory, "imported.links"); + var inputPath = Path.Combine(tempDirectory, "input.lino"); + var outputPath = Path.Combine(tempDirectory, "output.lino"); + await File.WriteAllLinesAsync(inputPath, new[] + { + "(1: 1 1)", + "(2: 1 2)", + "(3: 2 1)" + }); + + var result = await RunClinkAsync("--db", dbPath, "--import", inputPath, "--export", outputPath); + + AssertClinkSucceeded(result); + Assert.Equal(new[] + { + "(1: 1 1)", + "(2: 1 2)", + "(3: 2 1)" + }, File.ReadAllLines(outputPath)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static async Task RunClinkAsync(params string[] clinkArguments) { var csharpDirectory = FindCsharpDirectory(); diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs new file mode 100644 index 0000000..ec4132c --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseInputTests.cs @@ -0,0 +1,116 @@ +using Foundation.Data.Doublets.Cli; +using Platform.Data; +using Platform.Data.Doublets; + +namespace Foundation.Data.Doublets.Cli.Tests; + +public class LinoDatabaseInputTests +{ + [Fact] + public void ImportText_ReproducesNumberedLinksAtTheirExplicitIndexes() + { + WithNamedLinks(links => + { + LinoDatabaseInput.ImportText(links, """ + (1: 1 1) + (2: 1 2) + (3: 2 1) + """); + + Assert.Equal(new[] + { + "(1: 1 1)", + "(2: 1 2)", + "(3: 2 1)" + }, LinoDatabaseOutput.FormatDatabase(links)); + }); + } + + [Fact] + public void ImportText_CreatesNamedReferencesAsPointLinks() + { + WithNamedLinks(links => + { + LinoDatabaseInput.ImportText(links, "(child: father mother)"); + + var father = links.GetByName("father"); + var mother = links.GetByName("mother"); + var child = links.GetByName("child"); + + Assert.NotEqual(links.Constants.Null, father); + Assert.NotEqual(links.Constants.Null, mother); + Assert.NotEqual(links.Constants.Null, child); + + Assert.Equal(new Link(father, father, father), new Link(links.GetLink(father))); + Assert.Equal(new Link(mother, mother, mother), new Link(links.GetLink(mother))); + Assert.Equal(new Link(child, father, mother), new Link(links.GetLink(child))); + + Assert.Contains("(father: father father)", LinoDatabaseOutput.FormatDatabase(links)); + Assert.Contains("(mother: mother mother)", LinoDatabaseOutput.FormatDatabase(links)); + Assert.Contains("(child: father mother)", LinoDatabaseOutput.FormatDatabase(links)); + }); + } + + [Fact] + public void ImportText_TreatsOutOfRangeNumbersAsNames() + { + WithNamedLinks(links => + { + var outOfRangeNumber = ((ulong)uint.MaxValue + 1UL).ToString(); + + LinoDatabaseInput.ImportText(links, $"(child: \"{outOfRangeNumber}\" 2)"); + + var child = links.GetByName("child"); + var formattedLinks = string.Join(", ", LinoDatabaseOutput.FormatDatabase(links)); + + Assert.True(child != links.Constants.Null, formattedLinks); + var childLink = new Link(links.GetLink(child)); + var numericName = childLink.Source; + + Assert.Equal(outOfRangeNumber, links.GetName(numericName)); + Assert.Equal(new Link(numericName, numericName, numericName), new Link(links.GetLink(numericName))); + }); + } + + [Fact] + public void ImportText_UnquotesNamesWrittenByExporter() + { + WithNamedLinks(links => + { + LinoDatabaseInput.ImportText(links, """ + ('source name': 'source name' 'source name') + ('target:ref': 'target:ref' 'target:ref') + ('child(ref)': 'source name' 'target:ref') + """); + + var lines = LinoDatabaseOutput.FormatDatabase(links); + + Assert.Contains("('source name': 'source name' 'source name')", lines); + Assert.Contains("('target:ref': 'target:ref' 'target:ref')", lines); + Assert.Contains("('child(ref)': 'source name' 'target:ref')", lines); + }); + } + + private static void WithNamedLinks(Action> test) + { + var dbPath = Path.GetTempFileName(); + var namesDbPath = NamedTypesDecorator.MakeNamesDatabaseFilename(dbPath); + + try + { + var links = new NamedTypesDecorator(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/LinoDatabaseInput.cs b/csharp/Foundation.Data.Doublets.Cli/LinoDatabaseInput.cs new file mode 100644 index 0000000..c6c15a7 --- /dev/null +++ b/csharp/Foundation.Data.Doublets.Cli/LinoDatabaseInput.cs @@ -0,0 +1,282 @@ +using Platform.Data; +using Platform.Data.Doublets; +using System.Text; + +using DoubletLink = Platform.Data.Doublets.Link; + +namespace Foundation.Data.Doublets.Cli; + +public static class LinoDatabaseInput +{ + public static void ReadFromFile(INamedTypesLinks links, string path) + { + ImportText(links, File.ReadAllText(path)); + } + + public static void ImportText(INamedTypesLinks links, string linksNotation) + { + var context = new ImportContext(); + + foreach (var definition in ParseDefinitions(linksNotation)) + { + ImportLink(links, context, definition); + } + } + + private static void ImportLink(INamedTypesLinks links, ImportContext context, ImportDefinition definition) + { + var source = ResolveReference(links, context, definition.Source); + var target = ResolveReference(links, context, definition.Target); + var index = ResolveIndex(links, context, definition.Index); + UpdateLink(links, index, source, target); + } + + private static uint ResolveReference(INamedTypesLinks links, ImportContext context, string identifier) + { + if (identifier.Length == 0) + { + throw new FormatException("LiNo import references must have a value."); + } + + return TryParseSupportedReference(links, identifier, allowNull: true, out var link) + ? link + : EnsureNamedPointLink(links, context, identifier); + } + + private static uint ResolveIndex(INamedTypesLinks links, ImportContext context, string identifier) + { + if (TryParseSupportedReference(links, identifier, allowNull: false, out var link)) + { + if (!links.Exists(link)) + { + LinksExtensions.EnsureCreated(links, link); + } + + return link; + } + + return EnsureNamedPointLink(links, context, identifier); + } + + private static void UpdateLink(INamedTypesLinks links, uint index, uint source, uint target) + { + if (!links.Exists(index)) + { + LinksExtensions.EnsureCreated(links, index); + } + + var current = new DoubletLink(links.GetLink(index)); + if (current.Source == source && current.Target == target) + { + return; + } + + links.Update( + new DoubletLink(index, links.Constants.Any, links.Constants.Any), + new DoubletLink(index, source, target), + (_, _) => links.Constants.Continue); + } + + private static uint EnsureNamedPointLink(INamedTypesLinks links, ImportContext context, string name) + { + if (context.NamedReferences.TryGetValue(name, out var known)) + { + return known; + } + + var existing = links.GetByName(name); + if (existing != links.Constants.Null) + { + context.NamedReferences[name] = existing; + return existing; + } + + existing = FindByExistingName(links, name); + if (existing != links.Constants.Null) + { + context.NamedReferences[name] = existing; + return existing; + } + + var link = links.CreateAndUpdate(links.Constants.Null, links.Constants.Null); + links.SetName(link, name); + links.Update( + new DoubletLink(link, links.Constants.Null, links.Constants.Null), + new DoubletLink(link, link, link), + (_, _) => links.Constants.Continue); + context.NamedReferences[name] = link; + return link; + } + + private static uint FindByExistingName(INamedTypesLinks links, string name) + { + var any = links.Constants.Any; + var query = new DoubletLink(index: any, source: any, target: any); + + foreach (var link in links.All(query)) + { + var doublet = new DoubletLink(link); + if (links.GetName(doublet.Index) == name) + { + return doublet.Index; + } + } + + return links.Constants.Null; + } + + private static bool TryParseSupportedReference(INamedTypesLinks links, string identifier, bool allowNull, out uint link) + { + if (!uint.TryParse(identifier, out link)) + { + return false; + } + + if (allowNull && link == links.Constants.Null) + { + return true; + } + + return link != links.Constants.Null && link <= links.Constants.InternalReferencesRange.Maximum; + } + + private static IEnumerable ParseDefinitions(string linksNotation) + { + var lineNumber = 0; + foreach (var rawLine in linksNotation.Split('\n')) + { + lineNumber++; + var line = rawLine.Trim(); + if (line.Length == 0) + { + continue; + } + + yield return new LineParser(line, lineNumber).Parse(); + } + } + + private sealed class LineParser + { + private readonly string _line; + private readonly int _lineNumber; + private int _position; + + public LineParser(string line, int lineNumber) + { + _line = line; + _lineNumber = lineNumber; + } + + public ImportDefinition Parse() + { + SkipWhitespace(); + Expect('('); + var index = ReadReference(stopAtColon: true); + SkipWhitespace(); + Expect(':'); + var source = ReadReference(stopAtColon: false); + var target = ReadReference(stopAtColon: false); + SkipWhitespace(); + Expect(')'); + SkipWhitespace(); + + if (_position != _line.Length) + { + throw Error("Unexpected trailing content."); + } + + return new ImportDefinition(index, source, target); + } + + private string ReadReference(bool stopAtColon) + { + SkipWhitespace(); + if (_position >= _line.Length) + { + throw Error("Expected reference."); + } + + var first = _line[_position]; + if (first is '\'' or '"') + { + return ReadQuotedReference(first); + } + + var start = _position; + while (_position < _line.Length) + { + var current = _line[_position]; + if (char.IsWhiteSpace(current) || current == ')' || (stopAtColon && current == ':')) + { + break; + } + + _position++; + } + + if (_position == start) + { + throw Error("Expected reference."); + } + + return _line[start.._position]; + } + + private string ReadQuotedReference(char quote) + { + _position++; + var value = new StringBuilder(); + + while (_position < _line.Length) + { + var current = _line[_position++]; + if (current == quote) + { + return value.ToString(); + } + + if (current == '\\' && _position < _line.Length) + { + value.Append(_line[_position++]); + continue; + } + + value.Append(current); + } + + throw Error("Unterminated quoted reference."); + } + + private void Expect(char expected) + { + SkipWhitespace(); + if (_position >= _line.Length || _line[_position] != expected) + { + throw Error($"Expected '{expected}'."); + } + + _position++; + } + + private void SkipWhitespace() + { + while (_position < _line.Length && char.IsWhiteSpace(_line[_position])) + { + _position++; + } + } + + private FormatException Error(string message) + { + return new FormatException($"Invalid LiNo import line {_lineNumber}: {message}"); + } + } + + private sealed record ImportDefinition(string Index, string Source, string Target); + + private sealed class ImportContext + { + public Dictionary NamedReferences { get; } = new(StringComparer.Ordinal); + } +} diff --git a/csharp/Foundation.Data.Doublets.Cli/Program.cs b/csharp/Foundation.Data.Doublets.Cli/Program.cs index fe2d305..c667c2d 100644 --- a/csharp/Foundation.Data.Doublets.Cli/Program.cs +++ b/csharp/Foundation.Data.Doublets.Cli/Program.cs @@ -66,6 +66,11 @@ Description = "Path to write the complete database as a LiNo file" }; +var inputOption = new Option("--in", "--lino-input", "--import") +{ + Description = "Path to read and import a LiNo file into the database" +}; + var rootCommand = new RootCommand("LiNo CLI Tool for managing links data store"); rootCommand.Options.Add(dbOption); rootCommand.Options.Add(queryOption); @@ -76,6 +81,7 @@ rootCommand.Options.Add(beforeOption); rootCommand.Options.Add(changesOption); rootCommand.Options.Add(afterOption); +rootCommand.Options.Add(inputOption); rootCommand.Options.Add(outputOption); rootCommand.SetAction( @@ -90,10 +96,21 @@ var before = parseResult.GetValue(beforeOption); var changes = parseResult.GetValue(changesOption); var after = parseResult.GetValue(afterOption); + var inputPath = parseResult.GetValue(inputOption); var outputPath = parseResult.GetValue(outputOption); var decoratedLinks = new NamedTypesDecorator(db, trace); + if (before) + { + PrintAllLinks(decoratedLinks); + } + + if (!TryReadLinoInput(decoratedLinks, inputPath)) + { + return 1; + } + if (structure.HasValue) { var linkId = structure.Value; @@ -111,11 +128,6 @@ return TryWriteLinoOutput(decoratedLinks, outputPath) ? 0 : 1; } - if (before) - { - PrintAllLinks(decoratedLinks); - } - var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; var changesList = new List<(DoubletLink Before, DoubletLink After)>(); @@ -202,3 +214,22 @@ static bool TryWriteLinoOutput(INamedTypesLinks links, string? outputPath) return false; } } + +static bool TryReadLinoInput(INamedTypesLinks links, string? inputPath) +{ + if (string.IsNullOrWhiteSpace(inputPath)) + { + return true; + } + + try + { + LinoDatabaseInput.ReadFromFile(links, inputPath); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException || ex is NotSupportedException || ex is FormatException) + { + Console.Error.WriteLine($"Error reading LiNo input file '{inputPath}': {ex.Message}"); + return false; + } +} diff --git a/rust/changelog.d/20260508_104000_lino_input_import.md b/rust/changelog.d/20260508_104000_lino_input_import.md new file mode 100644 index 0000000..7da6412 --- /dev/null +++ b/rust/changelog.d/20260508_104000_lino_input_import.md @@ -0,0 +1,5 @@ +--- +bump: minor +--- + +Added `--in`/`--lino-input`/`--import` database import support for reading LiNo files into the links database with named references enabled by default. diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 1dad89e..9c60d5b 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -17,6 +17,7 @@ pub struct Cli { pub before: bool, pub changes: bool, pub after: bool, + pub lino_input: Option, pub lino_output: Option, } @@ -32,6 +33,7 @@ impl Default for Cli { before: false, changes: false, after: false, + lino_input: None, lino_output: None, } } @@ -101,6 +103,10 @@ impl Cli { cli.lino_output = Some(value.to_string()); continue; } + if let Some(value) = inline_value(&arg, &["--in", "--lino-input", "--import"]) { + cli.lino_input = Some(value.to_string()); + continue; + } match arg.as_str() { "-h" | "--help" => return Ok(CliCommand::Help), @@ -133,6 +139,9 @@ impl Cli { "--out" | "--lino-output" | "--export" => { cli.lino_output = Some(next_value(&mut args, &arg)?); } + "--in" | "--lino-input" | "--import" => { + cli.lino_input = Some(next_value(&mut args, &arg)?); + } "--" => { for value in args.by_ref() { set_positional_query(&mut cli, value)?; @@ -178,6 +187,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", + " --in , --lino-input , --import \n", + " Read and import a LiNo file into the database\n", " --out , --lino-output , --export \n", " Write the complete database as a LiNo file\n", " -h, --help\n", diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 05de5b9..85098e2 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -21,6 +21,7 @@ mod hybrid_reference; mod link; mod link_reference_validator; mod link_storage; +mod lino_database_input; mod lino_link; mod named_links; mod named_type_links; @@ -39,6 +40,7 @@ pub use error::LinkError; pub use hybrid_reference::{external_reference, external_reference_value, HybridReference}; pub use link::{DoubletsLink, Link}; pub use link_storage::LinkStorage; +pub use lino_database_input::{import_lino_file, import_lino_text}; pub use lino_link::LinoLink; pub use named_links::NamedLinks; pub use named_type_links::NamedTypeLinks; diff --git a/rust/src/lino_database_input.rs b/rust/src/lino_database_input.rs new file mode 100644 index 0000000..a293cc6 --- /dev/null +++ b/rust/src/lino_database_input.rs @@ -0,0 +1,159 @@ +//! LiNo database import helpers. + +use anyhow::{bail, Context, Result}; +use std::fs; +use std::path::Path; + +use crate::lino_link::LinoLink; +use crate::named_type_links::NamedTypeLinks; +use crate::parser::Parser; + +pub fn import_lino_file(storage: &mut T, path: P) -> Result<()> +where + T: NamedTypeLinks, + P: AsRef, +{ + let path = path.as_ref(); + let text = fs::read_to_string(path) + .with_context(|| format!("Failed to read LiNo input: {}", path.display()))?; + import_lino_text(storage, &text)?; + storage.save()?; + Ok(()) +} + +pub fn import_lino_text(storage: &mut T, links_notation: &str) -> Result<()> +where + T: NamedTypeLinks, +{ + let parser = Parser::new(); + let normalized_links_notation = normalize_links_notation(links_notation); + let links = parser.parse(&normalized_links_notation)?; + + for link in links.iter().filter(|link| !link.is_empty()) { + import_link(storage, link, false)?; + } + + Ok(()) +} + +fn import_link(storage: &mut T, link: &LinoLink, allow_anonymous_index: bool) -> Result +where + T: NamedTypeLinks, +{ + let values = link + .values + .as_ref() + .filter(|values| values.len() == 2) + .ok_or_else(|| anyhow::anyhow!("LiNo import supports links with exactly two values"))?; + + let source = resolve_reference(storage, &values[0])?; + let target = resolve_reference(storage, &values[1])?; + let identifier = normalize_identifier(link.id.as_deref()); + + if identifier.is_empty() { + if !allow_anonymous_index { + bail!("Top-level LiNo import links must have an index or name"); + } + + return Ok(storage.get_or_create(source, target)); + } + + let index = resolve_index(storage, &identifier)?; + update_link(storage, index, source, target)?; + Ok(index) +} + +fn resolve_reference(storage: &mut T, reference: &LinoLink) -> Result +where + T: NamedTypeLinks, +{ + if reference.values_count() == 2 { + return import_link(storage, reference, true); + } + + if reference.values_count() > 0 { + bail!( + "LiNo import references must be scalar values or nested links with exactly two values" + ); + } + + let identifier = normalize_identifier(reference.id.as_deref()); + if identifier.is_empty() { + bail!("LiNo import references must have a value"); + } + + if let Some(id) = parse_reference_number(&identifier, true) { + return Ok(id); + } + + storage.get_or_create_named(&identifier) +} + +fn resolve_index(storage: &mut T, identifier: &str) -> Result +where + T: NamedTypeLinks, +{ + if let Some(id) = parse_reference_number(identifier, false) { + if !storage.exists(id) { + storage.ensure_created(id); + } + + return Ok(id); + } + + storage.get_or_create_named(identifier) +} + +fn update_link(storage: &mut T, index: u32, source: u32, target: u32) -> Result<()> +where + T: NamedTypeLinks, +{ + if !storage.exists(index) { + storage.ensure_created(index); + } + + if let Some(current) = storage.get_link(index) { + if current.source == source && current.target == target { + return Ok(()); + } + } + + storage.update(index, source, target)?; + Ok(()) +} + +fn parse_reference_number(identifier: &str, allow_zero: bool) -> Option { + let id = identifier.parse::().ok()?; + if id == 0 && !allow_zero { + return None; + } + + Some(id) +} + +fn normalize_identifier(identifier: Option<&str>) -> String { + let normalized = identifier + .unwrap_or_default() + .trim() + .trim_end_matches(':') + .to_string(); + + if normalized.len() >= 2 && normalized.starts_with('\'') && normalized.ends_with('\'') { + return normalized[1..normalized.len() - 1].replace("\\'", "'"); + } + + if normalized.len() >= 2 && normalized.starts_with('"') && normalized.ends_with('"') { + return normalized[1..normalized.len() - 1].replace("\\\"", "\""); + } + + normalized +} + +fn normalize_links_notation(links_notation: &str) -> String { + links_notation + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n") +} diff --git a/rust/src/main.rs b/rust/src/main.rs index 8fa9726..cdea764 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -5,6 +5,7 @@ use anyhow::Result; use link_cli::cli::{Cli, CliCommand}; +use link_cli::import_lino_file; use link_cli::{NamedTypeLinks, NamedTypesDecorator, QueryProcessor}; fn main() -> Result<()> { @@ -23,6 +24,15 @@ fn main() -> Result<()> { // Create link storage with separate named-type aliases. let mut storage = NamedTypesDecorator::new(&cli.db, cli.trace)?; + // Print before state if requested + if cli.before { + storage.print_all_lino()?; + } + + if let Some(input_path) = &cli.lino_input { + import_lino_file(&mut storage, input_path)?; + } + // If --structure is provided, handle it separately if let Some(link_id) = cli.structure { let structure_formatted = storage.format_structure(link_id)?; @@ -33,11 +43,6 @@ fn main() -> Result<()> { return Ok(()); } - // Print before state if requested - if cli.before { - storage.print_all_lino()?; - } - // Get effective query (option takes precedence over positional argument) let effective_query = cli.query.as_deref().or(cli.query_arg.as_deref()); diff --git a/rust/tests/cli_arguments_tests.rs b/rust/tests/cli_arguments_tests.rs index 030008c..fc717df 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", + "--import", + "input.lino", "--out", "dump.lino", "-b", @@ -36,6 +38,7 @@ fn parses_csharp_option_aliases_without_direct_clap_dependency() { assert!(cli.trace); assert!(cli.auto_create_missing_references); assert_eq!(cli.structure, Some(42)); + assert_eq!(cli.lino_input.as_deref(), Some("input.lino")); assert_eq!(cli.lino_output.as_deref(), Some("dump.lino")); } @@ -58,6 +61,7 @@ fn parses_inline_alias_values_and_boolean_values() { "--before=true", "--changes=on", "--after=0", + "--lino-input=input.lino", "--lino-output=links.lino", ]); @@ -68,6 +72,7 @@ fn parses_inline_alias_values_and_boolean_values() { assert!(cli.before); assert!(cli.changes); assert!(!cli.after); + assert_eq!(cli.lino_input.as_deref(), Some("input.lino")); assert_eq!(cli.lino_output.as_deref(), Some("links.lino")); } diff --git a/rust/tests/cli_import_tests.rs b/rust/tests/cli_import_tests.rs new file mode 100644 index 0000000..9ae654e --- /dev/null +++ b/rust/tests/cli_import_tests.rs @@ -0,0 +1,45 @@ +use anyhow::{ensure, Result}; +use std::path::Path; +use std::process::{Command, Output}; +use tempfile::tempdir; + +#[test] +fn import_option_reads_numbered_lino_file() -> Result<()> { + let temp_dir = tempdir()?; + let db_path = temp_dir.path().join("imported.links"); + let input_path = temp_dir.path().join("input.lino"); + let output_path = temp_dir.path().join("output.lino"); + std::fs::write(&input_path, "(1: 1 1)\n(2: 1 2)\n(3: 2 1)\n")?; + + let output = run_clink(&db_path, &input_path, &output_path)?; + + ensure_success(&output)?; + assert_eq!( + std::fs::read_to_string(&output_path)?, + "(1: 1 1)\n(2: 1 2)\n(3: 2 1)\n" + ); + + Ok(()) +} + +fn run_clink(db_path: &Path, input_path: &Path, output_path: &Path) -> Result { + Ok(Command::new(env!("CARGO_BIN_EXE_clink")) + .arg("--db") + .arg(db_path) + .arg("--import") + .arg(input_path) + .arg("--export") + .arg(output_path) + .output()?) +} + +fn ensure_success(output: &Output) -> Result<()> { + ensure!( + output.status.success(), + "clink failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(()) +} diff --git a/rust/tests/lino_database_input_tests.rs b/rust/tests/lino_database_input_tests.rs new file mode 100644 index 0000000..006eb9d --- /dev/null +++ b/rust/tests/lino_database_input_tests.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use link_cli::{import_lino_text, LinkStorage, NamedTypeLinks}; +use tempfile::NamedTempFile; + +#[test] +fn import_lino_text_reproduces_numbered_links_at_explicit_indexes() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + let mut storage = LinkStorage::new(db_path, false)?; + + import_lino_text( + &mut storage, + r#" + (1: 1 1) + (2: 1 2) + (3: 2 1) + "#, + )?; + + assert_eq!( + storage.lino_lines(), + vec!["(1: 1 1)", "(2: 1 2)", "(3: 2 1)"] + ); + + Ok(()) +} + +#[test] +fn import_lino_text_creates_named_references_as_point_links() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + let mut storage = LinkStorage::new(db_path, false)?; + + import_lino_text(&mut storage, "(child: father mother)")?; + + let father = storage.get_by_name("father").expect("father should exist"); + let mother = storage.get_by_name("mother").expect("mother should exist"); + let child = storage.get_by_name("child").expect("child should exist"); + + let father_link = storage.get_link(father).expect("father link should exist"); + let mother_link = storage.get_link(mother).expect("mother link should exist"); + let child_link = storage.get_link(child).expect("child link should exist"); + + assert_eq!(father_link.source, father); + assert_eq!(father_link.target, father); + assert_eq!(mother_link.source, mother); + assert_eq!(mother_link.target, mother); + assert_eq!(child_link.source, father); + assert_eq!(child_link.target, mother); + + let lines = storage.lino_lines(); + assert!(lines.contains(&"(father: father father)".to_string())); + assert!(lines.contains(&"(mother: mother mother)".to_string())); + assert!(lines.contains(&"(child: father mother)".to_string())); + + Ok(()) +} + +#[test] +fn import_lino_text_treats_out_of_range_numbers_as_names() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + let mut storage = LinkStorage::new(db_path, false)?; + let out_of_range_number = u64::from(u32::MAX) + 1; + + import_lino_text(&mut storage, &format!("(child: {out_of_range_number} 1)"))?; + + let numeric_name = storage + .get_by_name(&out_of_range_number.to_string()) + .expect("out-of-range number should be named"); + let child = storage.get_by_name("child").expect("child should exist"); + + let numeric_name_link = storage + .get_link(numeric_name) + .expect("named number link should exist"); + let child_link = storage.get_link(child).expect("child link should exist"); + + assert_eq!(numeric_name_link.source, numeric_name); + assert_eq!(numeric_name_link.target, numeric_name); + assert_eq!(child_link.source, numeric_name); + + Ok(()) +}