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