Skip to main content

link_cli/
link_storage.rs

1//! LinkStorage - Persistent storage for links
2//!
3//! This module provides the LinkStorage struct for managing link persistence.
4
5use anyhow::{Context, Result};
6use doublets::decorators::DecoratorsExt;
7use doublets::Doublets;
8use std::collections::{HashMap, HashSet};
9use std::fs::{File, OpenOptions};
10use std::io::{BufRead, BufReader, BufWriter, Write};
11use std::path::{Path, PathBuf};
12
13use crate::error::LinkError;
14use crate::link::Link;
15use crate::storage::StorageRevision;
16
17/// Callback invoked once per `(before, after)` change a write produced.
18///
19/// The upstream decorators turn a single write into a cascade of changes, so
20/// the layers above the storage — names, transactions, the query processor —
21/// only stay in sync if they can see all of them. This is the equivalent of the
22/// `WriteHandler` the C# implementation threads through every decorator. A
23/// change whose `after` [`is null`](Link::is_null) is a deletion.
24pub type ChangeObserver<'a> = &'a mut dyn FnMut(Link, Link);
25
26/// Adapts a [`ChangeObserver`] to the `doublets` write handler signature.
27fn observe(
28    observer: &mut dyn FnMut(Link, Link),
29    before: doublets::Link<u32>,
30    after: doublets::Link<u32>,
31) -> doublets::data::Flow {
32    observer(Link::from(before), Link::from(after));
33    doublets::data::Flow::Continue
34}
35
36/// Prefix of the database line that records the freed addresses.
37///
38/// It is a comment so that a database written by this version still loads in
39/// one that predates it, and so that a database written before it still loads
40/// here — [`LinkStorage::restore_unused`] reconstructs the list when the line
41/// is absent.
42const UNUSED_HEADER: &str = "# unused:";
43
44/// LinkStorage provides persistent storage for links
45/// Corresponds to the storage functionality in NamedLinksDecorator in C#
46pub struct LinkStorage {
47    links: HashMap<u32, Link>,
48    names: HashMap<u32, String>,
49    name_to_id: HashMap<String, u32>,
50    /// The highest address ever handed out and not given back, i.e. the
51    /// `AllocatedLinks` counter of the C# store.
52    allocated: u32,
53    /// Addresses below [`Self::allocated`] that were freed and can be handed
54    /// out again, most recently freed last.
55    ///
56    /// The C# store keeps the same set as a linked list threaded through the
57    /// freed links themselves, pushing and popping at its head; a stack is the
58    /// same structure without the threading.
59    unused: Vec<u32>,
60    db_path: PathBuf,
61    revision: StorageRevision,
62    trace: bool,
63}
64
65impl LinkStorage {
66    /// Creates a new LinkStorage instance
67    ///
68    /// The database location is accepted as any [`AsRef<Path>`], so
69    /// embedding applications can pass a `PathBuf` (or an `OsStr` on
70    /// platforms with non-UTF-8 paths) instead of a `&str`.
71    pub fn new<P: AsRef<Path>>(db_path: P, trace: bool) -> Result<Self> {
72        let db_path = db_path.as_ref().to_path_buf();
73        let exists = db_path.exists();
74        let mut storage = Self {
75            links: HashMap::new(),
76            names: HashMap::new(),
77            name_to_id: HashMap::new(),
78            allocated: 0,
79            unused: Vec::new(),
80            db_path,
81            revision: StorageRevision::default(),
82            trace,
83        };
84
85        // Load existing database if it exists
86        if exists {
87            storage.load()?;
88        }
89        storage.revision = StorageRevision::of(&storage.db_path)?;
90
91        Ok(storage)
92    }
93
94    /// Creates a storage that lives only in memory: it never reads or writes
95    /// a file, and [`LinkStorage::save`] keeps nothing.
96    ///
97    /// This is the store of the browser workbench, where there is no file
98    /// system, so queries there merge, cascade and reuse addresses exactly as
99    /// they do in the CLI.
100    pub fn in_memory(trace: bool) -> Self {
101        Self {
102            links: HashMap::new(),
103            names: HashMap::new(),
104            name_to_id: HashMap::new(),
105            allocated: 0,
106            unused: Vec::new(),
107            db_path: PathBuf::new(),
108            revision: StorageRevision::default(),
109            trace,
110        }
111    }
112
113    /// Whether this storage was made by [`LinkStorage::in_memory`].
114    pub fn is_in_memory(&self) -> bool {
115        self.db_path.as_os_str().is_empty()
116    }
117
118    /// The database file this storage reads from and writes to, empty for an
119    /// [in-memory](LinkStorage::in_memory) storage.
120    pub fn database_path(&self) -> &Path {
121        &self.db_path
122    }
123
124    /// The revision of the database file observed at the last load or save.
125    pub fn observed_revision(&self) -> StorageRevision {
126        self.revision
127    }
128
129    /// Re-reads the database file's revision fingerprint, marking the
130    /// current on-disk state as "seen" for
131    /// [`LinksStorage::has_external_changes`](crate::LinksStorage::has_external_changes).
132    pub fn refresh_observed_revision(&mut self) -> Result<(), LinkError> {
133        if self.is_in_memory() {
134            return Ok(());
135        }
136        self.revision = StorageRevision::of(&self.db_path)?;
137        Ok(())
138    }
139
140    /// Discards in-memory state and re-reads the database file.
141    pub fn reload_from_disk(&mut self) -> Result<()> {
142        self.links.clear();
143        self.names.clear();
144        self.name_to_id.clear();
145        self.allocated = 0;
146        self.unused.clear();
147        if self.is_in_memory() {
148            return Ok(());
149        }
150        if self.db_path.exists() {
151            self.load()?;
152        }
153        self.revision = StorageRevision::of(&self.db_path)?;
154        Ok(())
155    }
156
157    /// Loads links from the database file
158    fn load(&mut self) -> Result<()> {
159        let file = File::open(&self.db_path)
160            .with_context(|| format!("Failed to open database: {}", self.db_path.display()))?;
161
162        let reader = BufReader::new(file);
163        let mut recorded_unused = None;
164
165        for line in reader.lines() {
166            let line = line?;
167            let line = line.trim();
168
169            if let Some(addresses) = line.strip_prefix(UNUSED_HEADER) {
170                recorded_unused = Some(Self::parse_unused_header(addresses));
171                continue;
172            }
173
174            if line.is_empty() || line.starts_with('#') {
175                continue;
176            }
177
178            // Parse link format: (index source target) or (index source target "name")
179            if let Some((link, name)) = self.parse_link_line(line) {
180                self.links.insert(link.index, link);
181                if link.index > self.allocated {
182                    self.allocated = link.index;
183                }
184                if let Some(name) = name {
185                    self.names.insert(link.index, name.clone());
186                    self.name_to_id.insert(name, link.index);
187                }
188            }
189        }
190
191        self.unused = self.restore_unused(recorded_unused);
192
193        if self.trace {
194            eprintln!(
195                "[TRACE] Loaded {} links from {}",
196                self.links.len(),
197                self.db_path.display()
198            );
199        }
200
201        Ok(())
202    }
203
204    /// Parses a single link line from the database
205    fn parse_link_line(&self, line: &str) -> Option<(Link, Option<String>)> {
206        // Simple format: (index source target) or (index source target "name")
207        let line = line.trim_matches(|c| c == '(' || c == ')');
208        let parts: Vec<&str> = line.split_whitespace().collect();
209
210        if parts.len() >= 3 {
211            let index = parts[0].parse().ok()?;
212            let source = parts[1].parse().ok()?;
213            let target = parts[2].parse().ok()?;
214            let name = if parts.len() > 3 {
215                Some(parts[3].trim_matches('"').to_string())
216            } else {
217                None
218            };
219            return Some((Link::new(index, source, target), name));
220        }
221
222        None
223    }
224
225    /// Parses the addresses recorded by an [`UNUSED_HEADER`] line into the
226    /// stack order the allocator uses.
227    ///
228    /// The line lists them the way the C# store's free list reads — most
229    /// recently freed first — and the stack pops from its end, so the two are
230    /// reverses of each other.
231    fn parse_unused_header(addresses: &str) -> Vec<u32> {
232        let mut unused: Vec<u32> = addresses
233            .split_whitespace()
234            .filter_map(|address| address.parse().ok())
235            .collect();
236        unused.reverse();
237        unused
238    }
239
240    /// The freed-address stack to start from after a load.
241    ///
242    /// A database this version wrote records the stack, because the order
243    /// decides which address the next link gets and nothing in the list of
244    /// stored links implies it. A database written before this version (or by
245    /// hand) does not, so the addresses missing below the highest stored one
246    /// are recovered instead, lowest reused first — an order the file does at
247    /// least determine.
248    ///
249    /// Addresses that a hand-edited file records but that are in use, or that
250    /// sit above the highest stored link, are dropped: handing them out would
251    /// overwrite a link or leave a hole the allocator would hand out twice.
252    fn restore_unused(&self, recorded: Option<Vec<u32>>) -> Vec<u32> {
253        match recorded {
254            Some(recorded) => {
255                let mut seen = HashSet::new();
256                recorded
257                    .into_iter()
258                    .filter(|address| {
259                        *address > 0
260                            && *address < self.allocated
261                            && !self.links.contains_key(address)
262                            && seen.insert(*address)
263                    })
264                    .collect()
265            }
266            None => (1..self.allocated)
267                .filter(|address| !self.links.contains_key(address))
268                .rev()
269                .collect(),
270        }
271    }
272
273    /// Hands out the address of the next link, reusing a freed one first.
274    ///
275    /// This is `ResizableDirectMemoryLinks.AllocateLink` in the C#
276    /// implementation: an address is only taken from beyond the end of the
277    /// store when no freed one is left. Reuse is observable — it decides the
278    /// address a query reports for a link it creates — so the two
279    /// implementations have to agree on it.
280    fn allocate(&mut self) -> u32 {
281        match self.unused.pop() {
282            Some(address) => address,
283            None => {
284                self.allocated += 1;
285                self.allocated
286            }
287        }
288    }
289
290    /// Gives `address` back to the allocator.
291    ///
292    /// Freeing the highest allocated address shrinks the store rather than
293    /// growing the free list, and takes with it every freed address that has
294    /// become the new end — `ResizableDirectMemoryLinks.Delete` does exactly
295    /// this, which is why C# reuses the address of a link it just appended
296    /// before it reuses one freed earlier.
297    fn release(&mut self, address: u32) {
298        if address == 0 || address > self.allocated {
299            return;
300        }
301        if address < self.allocated {
302            self.unused.push(address);
303            return;
304        }
305        self.allocated = address - 1;
306        while let Some(position) = self
307            .unused
308            .iter()
309            .position(|&freed| freed == self.allocated)
310        {
311            self.unused.remove(position);
312            self.allocated -= 1;
313        }
314    }
315
316    /// Saves all links to the database file; an in-memory storage has none.
317    pub fn save(&self) -> Result<()> {
318        if self.is_in_memory() {
319            return Ok(());
320        }
321        let file = OpenOptions::new()
322            .write(true)
323            .create(true)
324            .truncate(true)
325            .open(&self.db_path)
326            .with_context(|| format!("Failed to create database: {}", self.db_path.display()))?;
327
328        let mut writer = BufWriter::new(file);
329
330        // The freed addresses first: which of them the next link gets is not
331        // implied by the links that follow, and reloading has to resume the
332        // allocator exactly where it stopped.
333        if !self.unused.is_empty() {
334            let recorded: Vec<String> = self
335                .unused
336                .iter()
337                .rev()
338                .map(|address| address.to_string())
339                .collect();
340            writeln!(writer, "{UNUSED_HEADER} {}", recorded.join(" "))?;
341        }
342
343        // Sort by index for consistent output
344        let mut links: Vec<_> = self.links.values().collect();
345        links.sort_by_key(|l| l.index);
346
347        for link in links {
348            if let Some(name) = self.names.get(&link.index) {
349                writeln!(
350                    writer,
351                    "({} {} {} \"{}\")",
352                    link.index, link.source, link.target, name
353                )?;
354            } else {
355                writeln!(writer, "({} {} {})", link.index, link.source, link.target)?;
356            }
357        }
358
359        writer.flush()?;
360
361        if self.trace {
362            eprintln!(
363                "[TRACE] Saved {} links to {}",
364                self.links.len(),
365                self.db_path.display()
366            );
367        }
368
369        Ok(())
370    }
371
372    /// Creates a new link and returns its ID
373    ///
374    /// The address is the one `allocate` hands out: a freed one
375    /// when the store has any, and only otherwise a fresh one past the end.
376    pub fn create(&mut self, source: u32, target: u32) -> u32 {
377        let id = self.allocate();
378
379        let link = Link::new(id, source, target);
380        self.links.insert(id, link);
381
382        if self.trace {
383            eprintln!("[TRACE] Created link: ({} {} {})", id, source, target);
384        }
385
386        id
387    }
388
389    /// Creates the link at `id`, as an empty `(id: 0 0)` link.
390    ///
391    /// Reaching a specific address means asking the allocator for links until
392    /// it hands that one out, and the ones it handed out on the way are freed
393    /// again — they were never asked for. This is `ILinksExtensions.EnsureCreated`
394    /// in the C# implementation:
395    ///
396    /// ```csharp
397    /// do { createdLink = creator(); createdLinks.Add(createdLink); }
398    /// while (createdLink != max);
399    /// for (var i = 0; i < createdLinks.Count; i++)
400    ///     if (!nonExistentAddresses.Contains(createdLinks[i]))
401    ///         links.Delete(createdLinks[i]);
402    /// ```
403    ///
404    /// Freeing them in the order they were created is what leaves the last one
405    /// on top of the free list, so it is the address the next created link
406    /// gets.
407    pub fn ensure_created(&mut self, id: u32) -> u32 {
408        if id == 0 || self.links.contains_key(&id) {
409            return id;
410        }
411
412        let mut passed_over = Vec::new();
413        loop {
414            let created = self.create(0, 0);
415            if created == id {
416                break;
417            }
418            passed_over.push(created);
419        }
420
421        for address in passed_over {
422            let _ = self.delete_raw(address);
423        }
424
425        if self.trace {
426            eprintln!("[TRACE] Ensured link: ({} 0 0)", id);
427        }
428
429        id
430    }
431
432    /// Gets a link by ID
433    pub fn get(&self, id: u32) -> Option<&Link> {
434        self.links.get(&id)
435    }
436
437    /// Checks if a link exists
438    pub fn exists(&self, id: u32) -> bool {
439        self.links.contains_key(&id)
440    }
441
442    /// Updates a link's source and target **without** applying any policy.
443    ///
444    /// This is the raw store operation, the equivalent of writing straight to
445    /// `UnitedMemoryLinks` in the C# implementation. [`LinkStorage::update`]
446    /// wraps it with the upstream uniqueness/usages decorators; use this method
447    /// when you are supplying your own decorator stack (or deliberately want
448    /// none).
449    pub fn update_raw(&mut self, id: u32, source: u32, target: u32) -> Result<Link> {
450        if let Some(link) = self.links.get_mut(&id) {
451            let before = *link;
452            if self.trace {
453                eprintln!(
454                    "[TRACE] Updating link {} from ({} {}) to ({} {})",
455                    id, link.source, link.target, source, target
456                );
457            }
458            link.source = source;
459            link.target = target;
460            Ok(before)
461        } else {
462            Err(LinkError::not_found(id).into())
463        }
464    }
465
466    /// Deletes a link by ID **without** applying any policy.
467    ///
468    /// The raw counterpart of [`LinkStorage::delete`]: it removes exactly the
469    /// requested link (and its name), leaving any link that referenced it
470    /// dangling.
471    pub fn delete_raw(&mut self, id: u32) -> Result<Link> {
472        // Also remove the name mapping
473        if let Some(name) = self.names.remove(&id) {
474            self.name_to_id.remove(&name);
475        }
476
477        if let Some(link) = self.links.remove(&id) {
478            self.release(id);
479            if self.trace {
480                eprintln!(
481                    "[TRACE] Deleted link: ({} {} {})",
482                    link.index, link.source, link.target
483                );
484            }
485            Ok(link)
486        } else {
487            Err(LinkError::not_found(id).into())
488        }
489    }
490
491    /// Updates a link's source and target through the upstream
492    /// `doublets` uniqueness and usages resolution stack.
493    ///
494    /// This mirrors the C# implementation, which always talks to a
495    /// `UnitedMemoryLinks` wrapped in
496    /// `DecorateWithAutomaticUniquenessAndUsagesResolution()`. Concretely: if
497    /// another link already holds `(source, target)`, every reference to `id`
498    /// is re-pointed at that link and `id` is deleted, instead of storing a
499    /// duplicate doublet.
500    ///
501    /// Returns the state the link was in before the operation. Use
502    /// [`LinkStorage::update_raw`] for the undecorated write.
503    pub fn update(&mut self, id: u32, source: u32, target: u32) -> Result<Link> {
504        self.update_observed(id, source, target, &mut |_, _| {})
505    }
506
507    /// [`LinkStorage::update`], reporting every change the decorator stack made.
508    ///
509    /// Resolving a duplicate doublet re-points and deletes other links, so one
510    /// call can produce several changes. Layers above the storage need to see
511    /// all of them — the C# implementation gets them for free because its
512    /// decorators forward to a `WriteHandler`:
513    ///
514    /// ```csharp
515    /// var result = _links.Update(restriction, substitution, (before, after) => { ... });
516    /// ```
517    ///
518    /// `observer` is that handler. A change with a null `after` is a deletion.
519    ///
520    /// The upstream resolver also reports the duplicate a link is merged into
521    /// as an unchanged `(before, before)` pair — a documented deviation from
522    /// C#, whose `LinksUniquenessResolver` reports nothing for it. That pair
523    /// is not a change, so it is not passed on, and `--changes` lists the same
524    /// records in both languages.
525    pub fn update_observed(
526        &mut self,
527        id: u32,
528        source: u32,
529        target: u32,
530        observer: ChangeObserver<'_>,
531    ) -> Result<Link> {
532        let before = *self
533            .links
534            .get(&id)
535            .ok_or_else(|| LinkError::not_found(id))?;
536        let mut resolved = (&mut *self).with_automatic_uniqueness_and_usages_resolution();
537        resolved
538            .update_by_with(
539                [id],
540                [id, source, target],
541                &mut |before: doublets::Link<u32>, after| {
542                    let merged_into = before == after && before.index != id;
543                    if !merged_into {
544                        observer(Link::from(before), Link::from(after));
545                    }
546                    doublets::data::Flow::Continue
547                },
548            )
549            .map_err(LinkError::from)?;
550        Ok(before)
551    }
552
553    /// Deletes a link through the upstream `doublets` uniqueness and usages
554    /// resolution stack, cascading to every link that references it.
555    ///
556    /// This mirrors the C# implementation's
557    /// `DecorateWithAutomaticUniquenessAndUsagesResolution()` behaviour: the
558    /// link is reset to `(null, null)`, everything that still references it is
559    /// deleted first, and only then is the link itself removed. Cycles
560    /// terminate rather than recursing forever.
561    ///
562    /// Returns the state the requested link was in before the operation. Use
563    /// [`LinkStorage::delete_raw`] for the undecorated removal.
564    pub fn delete(&mut self, id: u32) -> Result<Link> {
565        self.delete_observed(id, &mut |_, _| {})
566    }
567
568    /// [`LinkStorage::delete`], reporting every change the decorator stack made.
569    ///
570    /// A cascading delete removes every link that still referenced `id`, so one
571    /// call can produce several changes; see [`LinkStorage::update_observed`].
572    pub fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result<Link> {
573        let before = *self
574            .links
575            .get(&id)
576            .ok_or_else(|| LinkError::not_found(id))?;
577        let mut resolved = (&mut *self).with_automatic_uniqueness_and_usages_resolution();
578        resolved
579            .delete_by_with([id], &mut |before, after| observe(observer, before, after))
580            .map_err(LinkError::from)?;
581        Ok(before)
582    }
583
584    /// Every stored link, ordered by address.
585    ///
586    /// The order is part of the contract, not an implementation detail: the
587    /// query processor enumerates links through this method, so an
588    /// unspecified order would make pattern matching — and with it the order
589    /// `--changes` reports and the order a cascading delete visits usages —
590    /// vary between runs of the very same query. `HashMap::values` is exactly
591    /// such an order, seeded randomly per process. Sorting reproduces what the
592    /// C# store does naturally: `UnitedMemoryLinks` walks allocated addresses
593    /// from `1` upwards.
594    pub fn all(&self) -> Vec<&Link> {
595        let mut links: Vec<&Link> = self.links.values().collect();
596        links.sort_unstable_by_key(|link| link.index);
597        links
598    }
599
600    /// Returns all links matching a query pattern
601    pub fn query(
602        &self,
603        index: Option<u32>,
604        source: Option<u32>,
605        target: Option<u32>,
606    ) -> Vec<&Link> {
607        self.links
608            .values()
609            .filter(|link| {
610                (index.is_none() || index == Some(link.index))
611                    && (source.is_none() || source == Some(link.source))
612                    && (target.is_none() || target == Some(link.target))
613            })
614            .collect()
615    }
616
617    /// Searches for a link with the given source and target.
618    ///
619    /// When several links share the pair, the lowest address wins, so the
620    /// result never depends on hash map iteration order.
621    pub fn search(&self, source: u32, target: u32) -> Option<u32> {
622        self.links
623            .values()
624            .filter(|link| link.source == source && link.target == target)
625            .map(|link| link.index)
626            .min()
627    }
628
629    /// Gets or creates a link with the given source and target.
630    ///
631    /// The two calls are fully qualified on purpose. [`LinkStorage`] also
632    /// implements the upstream [`Doublets`] trait — including *for
633    /// `&mut LinkStorage`*, so that a borrowed store can be decorated — and
634    /// inside an inherent `&mut self` method the receiver's type is exactly
635    /// `&mut LinkStorage`. Method resolution reaches the trait impl on the
636    /// reference before it derefs to the inherent impl, so a bare
637    /// `self.search(..)` silently resolves to [`Doublets::search`], which
638    /// interprets [`LinksConstants::any`](doublets::data::LinksConstants) as a
639    /// wildcard instead of matching it literally. Naming the inherent methods
640    /// keeps the exact-match semantics this function documents.
641    pub fn get_or_create(&mut self, source: u32, target: u32) -> u32 {
642        if let Some(id) = Self::search(self, source, target) {
643            id
644        } else {
645            Self::create(self, source, target)
646        }
647    }
648
649    /// Formats a link for display
650    pub fn format(&self, link: &Link) -> String {
651        // Use name if available
652        let index_str = self
653            .names
654            .get(&link.index)
655            .cloned()
656            .unwrap_or_else(|| link.index.to_string());
657        let source_str = self
658            .names
659            .get(&link.source)
660            .cloned()
661            .unwrap_or_else(|| link.source.to_string());
662        let target_str = self
663            .names
664            .get(&link.target)
665            .cloned()
666            .unwrap_or_else(|| link.target.to_string());
667        format!("({} {} {})", index_str, source_str, target_str)
668    }
669
670    /// Formats a link as LiNo suitable for database export.
671    pub fn format_lino(&self, link: &Link) -> String {
672        format!(
673            "({}: {} {})",
674            self.format_lino_reference(link.index),
675            self.format_lino_reference(link.source),
676            self.format_lino_reference(link.target)
677        )
678    }
679
680    /// Returns all database links as sorted LiNo lines.
681    pub fn lino_lines(&self) -> Vec<String> {
682        let mut links: Vec<_> = self.all();
683        links.sort_by_key(|l| l.index);
684        links
685            .into_iter()
686            .map(|link| self.format_lino(link))
687            .collect()
688    }
689
690    /// Writes the complete database as LiNo.
691    pub fn write_lino_output<P: AsRef<Path>>(&self, path: P) -> Result<()> {
692        let path = path.as_ref();
693        let file = OpenOptions::new()
694            .write(true)
695            .create(true)
696            .truncate(true)
697            .open(path)
698            .with_context(|| format!("Failed to create LiNo output: {}", path.display()))?;
699
700        let mut writer = BufWriter::new(file);
701        for line in self.lino_lines() {
702            writeln!(writer, "{line}")?;
703        }
704        writer.flush()?;
705        Ok(())
706    }
707
708    /// Formats the structure of a link
709    pub fn format_structure(&self, id: u32) -> Result<String> {
710        let mut visited = HashSet::new();
711        self.format_structure_recursive(id, &mut visited)
712    }
713
714    /// Recursively formats a link structure
715    fn format_structure_recursive(&self, id: u32, visited: &mut HashSet<u32>) -> Result<String> {
716        let link = self.get(id).ok_or(LinkError::not_found(id))?;
717        if !visited.insert(id) {
718            return Ok(self.format_lino_reference(id));
719        }
720
721        let source = if self.exists(link.source) && !visited.contains(&link.source) {
722            self.format_structure_recursive(link.source, visited)?
723        } else {
724            self.format_lino_reference(link.source)
725        };
726        let target = self.format_lino_reference(link.target);
727        let index = self.format_lino_reference(link.index);
728        visited.remove(&id);
729
730        Ok(format!("({index}: {source} {target})"))
731    }
732
733    /// Prints all links
734    pub fn print_all_links(&self) {
735        let mut links: Vec<_> = self.all();
736        links.sort_by_key(|l| l.index);
737        for link in links {
738            println!("{}", self.format(link));
739        }
740    }
741
742    /// Prints a change (before -> after)
743    pub fn print_change(&self, before: &Option<Link>, after: &Option<Link>) {
744        let before_text = before.map(|l| self.format(&l)).unwrap_or_default();
745        let after_text = after.map(|l| self.format(&l)).unwrap_or_default();
746        println!("({}) ({})", before_text, after_text);
747    }
748
749    // Named links functionality (corresponds to NamedLinks.cs)
750
751    /// Gets or creates a link with a name
752    pub fn get_or_create_named(&mut self, name: &str) -> u32 {
753        if let Some(&id) = self.name_to_id.get(name) {
754            id
755        } else {
756            // Create a self-referential link for the name
757            // Fully qualified for the same reason as in
758            // [`LinkStorage::get_or_create`]: the `Doublets` impl for
759            // `&mut LinkStorage` shadows the inherent `create`/`update`.
760            let id = Self::create(self, 0, 0);
761            Self::update(self, id, id, id).ok();
762            self.names.insert(id, name.to_string());
763            self.name_to_id.insert(name.to_string(), id);
764            if self.trace {
765                eprintln!("[TRACE] Created named link: {} => {}", name, id);
766            }
767            id
768        }
769    }
770
771    /// Sets the name for a link
772    pub fn set_name(&mut self, id: u32, name: &str) {
773        // Remove old name mapping if exists
774        if let Some(old_name) = self.names.remove(&id) {
775            self.name_to_id.remove(&old_name);
776        }
777        self.names.insert(id, name.to_string());
778        self.name_to_id.insert(name.to_string(), id);
779        if self.trace {
780            eprintln!("[TRACE] Set name: {} => {}", id, name);
781        }
782    }
783
784    /// Gets the name of a link
785    pub fn get_name(&self, id: u32) -> Option<&String> {
786        self.names.get(&id)
787    }
788
789    /// Gets a link ID by name
790    pub fn get_by_name(&self, name: &str) -> Option<u32> {
791        self.name_to_id.get(name).copied()
792    }
793
794    /// Removes the name for a link
795    pub fn remove_name(&mut self, id: u32) {
796        if let Some(name) = self.names.remove(&id) {
797            self.name_to_id.remove(&name);
798            if self.trace {
799                eprintln!("[TRACE] Removed name: {} => {}", id, name);
800            }
801        }
802    }
803
804    /// Returns true if trace mode is enabled
805    pub fn is_trace_enabled(&self) -> bool {
806        self.trace
807    }
808
809    fn format_lino_reference(&self, id: u32) -> String {
810        self.names
811            .get(&id)
812            .map(|name| escape_lino_reference(name))
813            .unwrap_or_else(|| id.to_string())
814    }
815}
816
817fn escape_lino_reference(reference: &str) -> String {
818    if reference.is_empty() || reference.trim().is_empty() {
819        return String::new();
820    }
821
822    let has_single_quote = reference.contains('\'');
823    let has_double_quote = reference.contains('"');
824    let needs_quoting = reference.contains(':')
825        || reference.contains('(')
826        || reference.contains(')')
827        || reference.contains(' ')
828        || reference.contains('\t')
829        || reference.contains('\n')
830        || reference.contains('\r')
831        || has_single_quote
832        || has_double_quote;
833
834    if has_single_quote && has_double_quote {
835        return format!("'{}'", reference.replace('\'', "\\'"));
836    }
837
838    if has_double_quote {
839        return format!("'{reference}'");
840    }
841
842    if has_single_quote {
843        return format!("\"{reference}\"");
844    }
845
846    if needs_quoting {
847        return format!("'{reference}'");
848    }
849
850    reference.to_string()
851}