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}