From 6f007d0d3c9b74fa4628e6262f842c0ec3ebbd6d Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Sep 2025 17:07:30 +0300 Subject: [PATCH 1/4] Initial commit with task details for issue #19 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/link-foundation/link-cli/issues/19 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3b45628 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/link-foundation/link-cli/issues/19 +Your prepared branch: issue-19-9de6817b +Your prepared working directory: /tmp/gh-issue-solver-1757513247527 + +Proceed. \ No newline at end of file From 16d2fdc11b81c52efb1ba0ab2199cd5fb803de9a Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Sep 2025 17:07:46 +0300 Subject: [PATCH 2/4] Remove CLAUDE.md - PR created successfully --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 3b45628..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/link-foundation/link-cli/issues/19 -Your prepared branch: issue-19-9de6817b -Your prepared working directory: /tmp/gh-issue-solver-1757513247527 - -Proceed. \ No newline at end of file From 1ca6785c301a1b791d606c2acbc653e0d7a228d1 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 10 Sep 2025 17:18:23 +0300 Subject: [PATCH 3/4] Fix structure formatting to hide link IDs in output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --structure option was showing link IDs in the formatted output like '(4:(2:(1: ) 1) 2)' instead of the expected simplified format like '((( ) 1) 2)'. Fixed by changing the FormatStructure call parameters from (true, true) to (false, false) to disable showing link IDs in the structure representation. Fixes #19 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Foundation.Data.Doublets.Cli/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Foundation.Data.Doublets.Cli/Program.cs b/Foundation.Data.Doublets.Cli/Program.cs index 1f9bfed..f74d58a 100644 --- a/Foundation.Data.Doublets.Cli/Program.cs +++ b/Foundation.Data.Doublets.Cli/Program.cs @@ -93,7 +93,7 @@ var linkId = structure.Value; try { - var structureFormatted = decoratedLinks.FormatStructure(linkId, link => decoratedLinks.IsFullPoint(linkId), true, true); + var structureFormatted = decoratedLinks.FormatStructure(linkId, link => decoratedLinks.IsFullPoint(linkId), false, false); Console.WriteLine(Namify(decoratedLinks, structureFormatted)); } catch (Exception ex) From 00d4b6c68a6adad0cb0214e9604f754b19d88e61 Mon Sep 17 00:00:00 2001 From: konard Date: Fri, 8 May 2026 06:25:41 +0000 Subject: [PATCH 4/4] Fix structure formatting with link indexes --- .../CliExportIntegrationTests.cs | 30 ++++++++++++ .../LinoDatabaseOutputTests.cs | 32 +++++++++++++ .../LinoDatabaseOutput.cs | 39 +++++++++++++++ .../Foundation.Data.Doublets.Cli/Program.cs | 4 +- rust/src/link_storage.rs | 48 ++++++------------- rust/src/named_type_links.rs | 37 +++++++------- rust/tests/cli_export_tests.rs | 38 +++++++++++++++ rust/tests/link_storage_tests.rs | 35 ++++++++++++++ 8 files changed, 208 insertions(+), 55 deletions(-) diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs index 080c6f1..f9831ee 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/CliExportIntegrationTests.cs @@ -54,6 +54,31 @@ public async Task ExportAlias_WritesNamedReferences() } } + [Fact] + public async Task StructureOption_RendersLeftBranchWithIndexes() + { + var tempDirectory = CreateTempDirectory(); + + try + { + var dbPath = Path.Combine(tempDirectory, "structure.links"); + + AssertClinkSucceeded(await RunClinkAsync("--db", dbPath, "() ((1: 1 1))")); + AssertClinkSucceeded(await RunClinkAsync("--db", dbPath, "() ((2: 1 2))")); + AssertClinkSucceeded(await RunClinkAsync("--db", dbPath, "() ((3: 2 1))")); + AssertClinkSucceeded(await RunClinkAsync("--db", dbPath, "() ((4: 3 2))")); + + var result = await RunClinkAsync("--db", dbPath, "--structure", "4"); + + AssertClinkSucceeded(result); + Assert.Equal("(4: (3: (2: (1: 1 1) 2) 1) 2)\n", NormalizeNewlines(result.Stdout)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static async Task RunClinkAsync(params string[] clinkArguments) { var csharpDirectory = FindCsharpDirectory(); @@ -104,6 +129,11 @@ private static void AssertClinkSucceeded(CommandResult result) $"clink exited with {result.ExitCode}\nstdout:\n{result.Stdout}\nstderr:\n{result.Stderr}"); } + private static string NormalizeNewlines(string text) + { + return text.Replace("\r\n", "\n"); + } + private static string FindCsharpDirectory() { var directory = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs index 75a84b5..9e06bec 100644 --- a/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs +++ b/csharp/Foundation.Data.Doublets.Cli.Tests/LinoDatabaseOutputTests.cs @@ -113,6 +113,38 @@ public void WriteToFile_WritesCompleteDatabaseAsLinoLines() }); } + [Fact] + public void FormatStructure_RendersLeftBranchWithLinkIndexes() + { + WithNamedLinks(links => + { + var first = links.GetOrCreate(0u, 0u); + var second = links.GetOrCreate(first, first); + var third = links.GetOrCreate(second, first); + var fourth = links.GetOrCreate(third, second); + + var formatted = LinoDatabaseOutput.FormatStructure(links, fourth); + + Assert.Equal("(4: (3: (2: (1: 0 0) 1) 1) 2)", formatted); + }); + } + + [Fact] + public void FormatStructure_RendersRepeatedSourceAndTargetAsReferenceOnRight() + { + WithNamedLinks(links => + { + var first = links.GetOrCreate(0u, 0u); + var second = links.GetOrCreate(first, first); + links.GetOrCreate(second, first); + var fourth = links.GetOrCreate(second, second); + + var formatted = LinoDatabaseOutput.FormatStructure(links, fourth); + + Assert.Equal("(4: (2: (1: 0 0) 1) 2)", formatted); + }); + } + private static void WithNamedLinks(Action> test) { var dbPath = Path.GetTempFileName(); diff --git a/csharp/Foundation.Data.Doublets.Cli/LinoDatabaseOutput.cs b/csharp/Foundation.Data.Doublets.Cli/LinoDatabaseOutput.cs index 53c1a0a..5599007 100644 --- a/csharp/Foundation.Data.Doublets.Cli/LinoDatabaseOutput.cs +++ b/csharp/Foundation.Data.Doublets.Cli/LinoDatabaseOutput.cs @@ -49,6 +49,12 @@ public static string FormatChange(INamedTypesLinks links, DoubletLink link return $"({beforeText}) ({afterText})"; } + public static string FormatStructure(INamedTypesLinks links, uint linkId) + { + var visited = new HashSet(); + return FormatStructure(links, linkId, visited); + } + public static string Namify(INamedTypesLinks namedLinks, string linksNotation) { return NumberTokenRegex.Replace(linksNotation, match => @@ -59,6 +65,39 @@ public static string Namify(INamedTypesLinks namedLinks, string linksNotat }); } + private static string FormatStructure(INamedTypesLinks links, uint linkId, HashSet visited) + { + if (!links.Exists(linkId)) + { + throw new InvalidOperationException($"Link '{linkId}' does not exist."); + } + + if (!visited.Add(linkId)) + { + return FormatReference(links, linkId); + } + + try + { + var link = new DoubletLink(links.GetLink(linkId)); + var source = FormatStructureSource(links, link.Source, visited); + var target = FormatReference(links, link.Target); + + return $"({FormatReference(links, link.Index)}: {source} {target})"; + } + finally + { + visited.Remove(linkId); + } + } + + private static string FormatStructureSource(INamedTypesLinks links, uint linkId, HashSet visited) + { + return links.Exists(linkId) && !visited.Contains(linkId) + ? FormatStructure(links, linkId, visited) + : FormatReference(links, linkId); + } + private static string FormatReference(INamedTypesLinks links, uint link) { var name = links.GetName(link); diff --git a/csharp/Foundation.Data.Doublets.Cli/Program.cs b/csharp/Foundation.Data.Doublets.Cli/Program.cs index eb40d4c..fe2d305 100644 --- a/csharp/Foundation.Data.Doublets.Cli/Program.cs +++ b/csharp/Foundation.Data.Doublets.Cli/Program.cs @@ -99,8 +99,8 @@ var linkId = structure.Value; try { - var structureFormatted = decoratedLinks.FormatStructure(linkId, link => decoratedLinks.IsFullPoint(linkId), true, true); - Console.WriteLine(LinoDatabaseOutput.Namify(decoratedLinks, structureFormatted)); + var structureFormatted = LinoDatabaseOutput.FormatStructure(decoratedLinks, linkId); + Console.WriteLine(structureFormatted); } catch (Exception ex) { diff --git a/rust/src/link_storage.rs b/rust/src/link_storage.rs index f40c094..ffc3ce9 100644 --- a/rust/src/link_storage.rs +++ b/rust/src/link_storage.rs @@ -3,7 +3,7 @@ //! This module provides the LinkStorage struct for managing link persistence. use anyhow::{Context, Result}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, BufWriter, Write}; use std::path::Path; @@ -339,47 +339,27 @@ impl LinkStorage { /// Formats the structure of a link pub fn format_structure(&self, id: u32) -> Result { - if let Some(link) = self.get(id) { - Ok(self.format_structure_recursive(link, true)) - } else { - Err(LinkError::NotFound(id).into()) - } + let mut visited = HashSet::new(); + self.format_structure_recursive(id, &mut visited) } /// Recursively formats a link structure - fn format_structure_recursive(&self, link: &Link, is_root: bool) -> String { - if link.is_full_point() && !is_root { - // Self-referential point - just show the name/id - return self - .names - .get(&link.index) - .cloned() - .unwrap_or_else(|| link.index.to_string()); + fn format_structure_recursive(&self, id: u32, visited: &mut HashSet) -> Result { + let link = self.get(id).ok_or(LinkError::NotFound(id))?; + if !visited.insert(id) { + return Ok(self.format_lino_reference(id)); } - let source_str = if link.source == link.index { - self.names - .get(&link.index) - .cloned() - .unwrap_or_else(|| link.index.to_string()) - } else if let Some(source_link) = self.get(link.source) { - self.format_structure_recursive(source_link, false) - } else { - link.source.to_string() - }; - - let target_str = if link.target == link.index { - self.names - .get(&link.index) - .cloned() - .unwrap_or_else(|| link.index.to_string()) - } else if let Some(target_link) = self.get(link.target) { - self.format_structure_recursive(target_link, false) + let source = if self.exists(link.source) && !visited.contains(&link.source) { + self.format_structure_recursive(link.source, visited)? } else { - link.target.to_string() + self.format_lino_reference(link.source) }; + let target = self.format_lino_reference(link.target); + let index = self.format_lino_reference(link.index); + visited.remove(&id); - format!("({} {})", source_str, target_str) + Ok(format!("({index}: {source} {target})")) } /// Prints all links diff --git a/rust/src/named_type_links.rs b/rust/src/named_type_links.rs index b32a37a..21e6832 100644 --- a/rust/src/named_type_links.rs +++ b/rust/src/named_type_links.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use std::collections::HashSet; use std::fs::OpenOptions; use std::io::{BufWriter, Write}; use std::path::Path; @@ -99,32 +100,30 @@ pub trait NamedTypeLinks { } fn format_structure(&mut self, id: u32) -> Result { - let link = self.get_link(id).ok_or(LinkError::NotFound(id))?; - self.format_structure_recursive(&link, true) + let mut visited = HashSet::new(); + self.format_structure_recursive(id, &mut visited) } - fn format_structure_recursive(&mut self, link: &Link, is_root: bool) -> Result { - if link.is_full_point() && !is_root { - return self.format_reference(link.index); + fn format_structure_recursive( + &mut self, + id: u32, + visited: &mut HashSet, + ) -> Result { + let link = self.get_link(id).ok_or(LinkError::NotFound(id))?; + if !visited.insert(id) { + return self.format_reference(id); } - let source = if link.source == link.index { - self.format_reference(link.index)? - } else if let Some(source_link) = self.get_link(link.source) { - self.format_structure_recursive(&source_link, false)? - } else { - link.source.to_string() - }; - - let target = if link.target == link.index { - self.format_reference(link.index)? - } else if let Some(target_link) = self.get_link(link.target) { - self.format_structure_recursive(&target_link, false)? + let source = if self.exists(link.source) && !visited.contains(&link.source) { + self.format_structure_recursive(link.source, visited)? } else { - link.target.to_string() + self.format_reference(link.source)? }; + let target = self.format_reference(link.target)?; + let index = self.format_reference(link.index)?; + visited.remove(&id); - Ok(format!("({source} {target})")) + Ok(format!("({index}: {source} {target})")) } } diff --git a/rust/tests/cli_export_tests.rs b/rust/tests/cli_export_tests.rs index 94b74df..e51130c 100644 --- a/rust/tests/cli_export_tests.rs +++ b/rust/tests/cli_export_tests.rs @@ -37,6 +37,27 @@ fn export_alias_writes_named_references() -> Result<()> { Ok(()) } +#[test] +fn structure_option_renders_left_branch_with_indexes() -> Result<()> { + let temp_dir = tempdir()?; + let db_path = temp_dir.path().join("structure.links"); + + ensure_success(&run_query(&db_path, "() ((1: 1 1))")?)?; + ensure_success(&run_query(&db_path, "() ((2: 1 2))")?)?; + ensure_success(&run_query(&db_path, "() ((3: 2 1))")?)?; + ensure_success(&run_query(&db_path, "() ((4: 3 2))")?)?; + + let output = run_structure(&db_path, 4)?; + + ensure_success(&output)?; + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "(4: (3: (2: (1: 1 1) 2) 1) 2)\n" + ); + + Ok(()) +} + fn run_clink( db_path: &Path, query: &str, @@ -56,6 +77,23 @@ fn run_clink( .output()?) } +fn run_query(db_path: &Path, query: &str) -> Result { + Ok(Command::new(env!("CARGO_BIN_EXE_clink")) + .arg("--db") + .arg(db_path) + .arg(query) + .output()?) +} + +fn run_structure(db_path: &Path, structure: u32) -> Result { + Ok(Command::new(env!("CARGO_BIN_EXE_clink")) + .arg("--db") + .arg(db_path) + .arg("--structure") + .arg(structure.to_string()) + .output()?) +} + fn ensure_success(output: &Output) -> Result<()> { ensure!( output.status.success(), diff --git a/rust/tests/link_storage_tests.rs b/rust/tests/link_storage_tests.rs index 4137752..6bfcf8a 100644 --- a/rust/tests/link_storage_tests.rs +++ b/rust/tests/link_storage_tests.rs @@ -225,3 +225,38 @@ fn test_write_lino_output_writes_complete_database() -> Result<()> { Ok(()) } + +#[test] +fn test_format_structure_renders_left_branch_with_link_indexes() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let first = storage.create(0, 0); + let second = storage.create(first, first); + let third = storage.create(second, first); + let fourth = storage.create(third, second); + + assert_eq!( + storage.format_structure(fourth)?, + "(4: (3: (2: (1: 0 0) 1) 1) 2)" + ); + + Ok(()) +} + +#[test] +fn test_format_structure_renders_repeated_source_and_target_as_reference_on_right() -> Result<()> { + let temp_file = NamedTempFile::new()?; + let db_path = temp_file.path().to_str().unwrap(); + + let mut storage = LinkStorage::new(db_path, false)?; + let first = storage.create(0, 0); + let second = storage.create(first, first); + storage.create(second, first); + let fourth = storage.create(second, second); + + assert_eq!(storage.format_structure(fourth)?, "(4: (2: (1: 0 0) 1) 2)"); + + Ok(()) +}