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}