1use std::collections::{BTreeMap, BTreeSet};
9use std::error::Error;
10use std::fmt;
11use std::fmt::Write as _;
12
13use lino_objects_codec::format::parse_indented;
14
15use crate::engine::{stable_id, KNOWLEDGE_SCHEMA_VERSION};
16use crate::link_store::{DoubletLink, LinkRecord};
17use crate::links_format::push_lino_node;
18use crate::seed::parser::{parse_lino, LinoNode};
19
20const DEFAULT_MAX_APPLICATIONS: usize = 64;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24pub enum CrudEvent {
25 Manual,
26 Create,
27 Read,
28 Update,
29 Delete,
30}
31
32impl CrudEvent {
33 #[must_use]
34 pub const fn as_str(self) -> &'static str {
35 match self {
36 Self::Manual => "manual",
37 Self::Create => "create",
38 Self::Read => "read",
39 Self::Update => "update",
40 Self::Delete => "delete",
41 }
42 }
43
44 fn parse(value: &str) -> Result<Self, SubstitutionRuleError> {
45 match value.trim().to_ascii_lowercase().as_str() {
46 "manual" | "apply" | "learned" => Ok(Self::Manual),
47 "create" | "created" => Ok(Self::Create),
48 "read" | "select" | "query" => Ok(Self::Read),
49 "update" | "updated" => Ok(Self::Update),
50 "delete" | "deleted" => Ok(Self::Delete),
51 other => Err(SubstitutionRuleError::InvalidEvent(other.to_owned())),
52 }
53 }
54}
55
56impl fmt::Display for CrudEvent {
57 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58 formatter.write_str(self.as_str())
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
64pub struct SubstitutionLink {
65 pub from: String,
66 pub to: String,
67}
68
69impl SubstitutionLink {
70 #[must_use]
71 pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
72 Self {
73 from: from.into(),
74 to: to.into(),
75 }
76 }
77
78 #[must_use]
79 pub fn pattern_text(&self) -> String {
80 format!("{} -> {}", self.from, self.to)
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct LinkPattern {
88 from: PatternNode,
89 to: PatternNode,
90}
91
92impl LinkPattern {
93 pub fn parse(text: &str) -> Result<Self, SubstitutionRuleError> {
94 let Some((from, to)) = text.split_once("->") else {
95 return Err(SubstitutionRuleError::InvalidPattern(format!(
96 "expected `from -> to`, got `{text}`"
97 )));
98 };
99 let from = PatternNode::parse(from.trim())?;
100 let to = PatternNode::parse(to.trim())?;
101 Ok(Self { from, to })
102 }
103
104 #[must_use]
105 pub fn literal_pair(&self) -> Option<(&str, &str)> {
106 match (&self.from, &self.to) {
107 (PatternNode::Literal(from), PatternNode::Literal(to)) => Some((from, to)),
108 _ => None,
109 }
110 }
111
112 fn instantiate(&self, bindings: &BTreeMap<String, String>) -> Option<SubstitutionLink> {
113 Some(SubstitutionLink {
114 from: self.from.instantiate(bindings)?,
115 to: self.to.instantiate(bindings)?,
116 })
117 }
118}
119
120impl fmt::Display for LinkPattern {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 write!(formatter, "{} -> {}", self.from, self.to)
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127enum PatternNode {
128 Literal(String),
129 Variable(String),
130 PrefixVariable { prefix: String, variable: String },
131}
132
133impl PatternNode {
134 fn parse(text: &str) -> Result<Self, SubstitutionRuleError> {
135 if text.is_empty() {
136 return Err(SubstitutionRuleError::InvalidPattern(String::from(
137 "pattern node is empty",
138 )));
139 }
140 if let Some(variable) = text.strip_prefix('$') {
141 validate_variable(variable, text)?;
142 return Ok(Self::Variable(variable.to_owned()));
143 }
144 if let Some((prefix, variable)) = text.split_once('$') {
145 validate_variable(variable, text)?;
146 return Ok(Self::PrefixVariable {
147 prefix: prefix.to_owned(),
148 variable: variable.to_owned(),
149 });
150 }
151 Ok(Self::Literal(text.to_owned()))
152 }
153
154 fn instantiate(&self, bindings: &BTreeMap<String, String>) -> Option<String> {
155 match self {
156 Self::Literal(value) => Some(value.clone()),
157 Self::Variable(variable) => bindings.get(variable).cloned(),
158 Self::PrefixVariable { prefix, variable } => bindings
159 .get(variable)
160 .map(|value| format!("{prefix}{value}")),
161 }
162 }
163}
164
165impl fmt::Display for PatternNode {
166 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167 match self {
168 Self::Literal(value) => formatter.write_str(value),
169 Self::Variable(variable) => write!(formatter, "${variable}"),
170 Self::PrefixVariable { prefix, variable } => write!(formatter, "{prefix}${variable}"),
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct SubstitutionAction {
178 pub remove: LinkPattern,
179 pub add: Vec<LinkPattern>,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct SubstitutionRule {
185 pub id: String,
186 pub order: i64,
187 pub events: Vec<CrudEvent>,
188 pub conditions: Vec<LinkPattern>,
189 pub actions: Vec<SubstitutionAction>,
190}
191
192impl SubstitutionRule {
193 #[must_use]
194 pub fn matches_event(&self, event: CrudEvent) -> bool {
195 self.events.contains(&event)
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct SubstitutionRuleSet {
202 pub id: String,
203 pub rules: Vec<SubstitutionRule>,
204}
205
206impl SubstitutionRuleSet {
207 pub fn from_links_notation(text: &str) -> Result<Self, SubstitutionRuleError> {
208 let trimmed = text.trim();
209 if trimmed.is_empty() {
210 return Err(SubstitutionRuleError::EmptyDocument);
211 }
212 parse_indented(trimmed)
213 .map_err(|error| SubstitutionRuleError::IllFormedLinksNotation(format!("{error:?}")))?;
214 let tree = parse_lino(trimmed);
215 let root = tree
216 .children
217 .first()
218 .ok_or(SubstitutionRuleError::EmptyDocument)?;
219 if root.name != "substitution_rules" {
220 return Err(SubstitutionRuleError::NotSubstitutionRules(
221 root.name.clone(),
222 ));
223 }
224 let mut rules = root
225 .children
226 .iter()
227 .filter(|child| child.name == "rule")
228 .map(parse_rule)
229 .collect::<Result<Vec<_>, _>>()?;
230 rules.sort_by(|left, right| left.order.cmp(&right.order).then(left.id.cmp(&right.id)));
231 let child_id = root.find_child_value("id");
232 let id = if child_id.is_empty() {
233 root.id.clone()
234 } else {
235 child_id.to_owned()
236 };
237 Ok(Self { id, rules })
238 }
239
240 #[must_use]
241 pub fn links_notation(&self) -> String {
242 let mut out = String::new();
243 push_lino_node(&mut out, 0, "substitution_rules", None);
244 push_lino_node(&mut out, 2, "id", Some(&self.id));
245 for rule in &self.rules {
246 push_lino_node(&mut out, 2, "rule", Some(&rule.id));
247 push_lino_node(&mut out, 4, "order", Some(&rule.order.to_string()));
248 for event in &rule.events {
249 push_lino_node(&mut out, 4, "event", Some(event.as_str()));
250 }
251 for condition in &rule.conditions {
252 push_lino_node(&mut out, 4, "when", Some(&condition.to_string()));
253 }
254 for action in &rule.actions {
255 push_lino_node(&mut out, 4, "replace", Some(&action.remove.to_string()));
256 for add in &action.add {
257 push_lino_node(&mut out, 6, "with", Some(&add.to_string()));
258 }
259 }
260 }
261 out.trim_end().to_owned()
262 }
263}
264
265#[derive(Debug, Default, Clone, PartialEq, Eq)]
267pub struct SubstitutionGraph {
268 links: BTreeSet<SubstitutionLink>,
269}
270
271impl SubstitutionGraph {
272 #[must_use]
273 pub const fn new() -> Self {
274 Self {
275 links: BTreeSet::new(),
276 }
277 }
278
279 #[must_use]
280 pub fn with_link(mut self, from: &str, to: &str) -> Self {
281 self.insert_link(from, to);
282 self
283 }
284
285 pub fn insert_link(&mut self, from: &str, to: &str) -> bool {
286 self.links.insert(SubstitutionLink::new(from, to))
287 }
288
289 pub fn remove_link(&mut self, from: &str, to: &str) -> bool {
290 self.links.remove(&SubstitutionLink::new(from, to))
291 }
292
293 #[must_use]
294 pub fn contains_link(&self, from: &str, to: &str) -> bool {
295 self.links.contains(&SubstitutionLink::new(from, to))
296 }
297
298 #[must_use]
299 pub fn links(&self) -> Vec<SubstitutionLink> {
300 self.links.iter().cloned().collect()
301 }
302
303 #[must_use]
304 pub fn links_notation(&self) -> String {
305 let mut out = String::new();
306 push_lino_node(&mut out, 0, "substitution_graph", None);
307 push_lino_node(
308 &mut out,
309 2,
310 "id",
311 Some(&stable_id("substitution_graph", &self.canonical_links())),
312 );
313 for link in &self.links {
314 push_lino_node(&mut out, 2, "link", Some(&link.pattern_text()));
315 }
316 out.trim_end().to_owned()
317 }
318
319 #[must_use]
320 pub fn apply_rules(
321 &mut self,
322 rules: &SubstitutionRuleSet,
323 event: CrudEvent,
324 ) -> SubstitutionTraceReport {
325 self.apply_rules_with_limit(rules, event, DEFAULT_MAX_APPLICATIONS)
326 }
327
328 #[must_use]
329 pub fn apply_rules_with_limit(
330 &mut self,
331 rules: &SubstitutionRuleSet,
332 event: CrudEvent,
333 max_applications: usize,
334 ) -> SubstitutionTraceReport {
335 let mut report = SubstitutionTraceReport::new(event);
336 while report.traces.len() < max_applications {
337 let Some(trace) = self.apply_first_rule(rules, event, report.traces.len()) else {
338 return report;
339 };
340 report.traces.push(trace);
341 }
342 let mut probe = self.clone();
343 report.terminated_by_guard = probe
344 .apply_first_rule(rules, event, report.traces.len())
345 .is_some();
346 report
347 }
348
349 #[must_use]
350 pub fn create_link(
351 &mut self,
352 from: &str,
353 to: &str,
354 rules: &SubstitutionRuleSet,
355 ) -> SubstitutionTraceReport {
356 self.insert_link(from, to);
357 self.apply_rules(rules, CrudEvent::Create)
358 }
359
360 #[must_use]
361 pub fn read_link(
362 &mut self,
363 from: &str,
364 to: &str,
365 rules: &SubstitutionRuleSet,
366 ) -> (bool, SubstitutionTraceReport) {
367 let exists = self.contains_link(from, to);
368 let report = self.apply_rules(rules, CrudEvent::Read);
369 (exists, report)
370 }
371
372 #[must_use]
373 pub fn update_link(
374 &mut self,
375 old_from: &str,
376 old_to: &str,
377 new_from: &str,
378 new_to: &str,
379 rules: &SubstitutionRuleSet,
380 ) -> SubstitutionTraceReport {
381 self.remove_link(old_from, old_to);
382 self.insert_link(new_from, new_to);
383 self.apply_rules(rules, CrudEvent::Update)
384 }
385
386 #[must_use]
387 pub fn delete_link(
388 &mut self,
389 from: &str,
390 to: &str,
391 rules: &SubstitutionRuleSet,
392 ) -> SubstitutionTraceReport {
393 self.remove_link(from, to);
394 self.apply_rules(rules, CrudEvent::Delete)
395 }
396
397 fn apply_first_rule(
398 &mut self,
399 rules: &SubstitutionRuleSet,
400 event: CrudEvent,
401 sequence: usize,
402 ) -> Option<SubstitutionTrace> {
403 rules
404 .rules
405 .iter()
406 .filter(|rule| rule.matches_event(event))
407 .find_map(|rule| self.apply_rule(rule, event, sequence))
408 }
409
410 fn apply_rule(
411 &mut self,
412 rule: &SubstitutionRule,
413 event: CrudEvent,
414 sequence: usize,
415 ) -> Option<SubstitutionTrace> {
416 let mut required_patterns = rule.conditions.clone();
417 for action in &rule.actions {
418 required_patterns.push(action.remove.clone());
419 }
420 let bindings = self.find_bindings(&required_patterns)?;
421 let before = self.links.clone();
422 let mut removed = Vec::new();
423 let mut added = Vec::new();
424 for action in &rule.actions {
425 let remove = action.remove.instantiate(&bindings)?;
426 if self.links.remove(&remove) {
427 removed.push(remove);
428 }
429 for add in &action.add {
430 let link = add.instantiate(&bindings)?;
431 if self.links.insert(link.clone()) {
432 added.push(link);
433 }
434 }
435 }
436 if self.links == before {
437 return None;
438 }
439 Some(SubstitutionTrace::new(
440 sequence, &rule.id, event, bindings, removed, added,
441 ))
442 }
443
444 fn find_bindings(&self, patterns: &[LinkPattern]) -> Option<BTreeMap<String, String>> {
445 self.find_bindings_from(patterns, BTreeMap::new())
446 }
447
448 fn find_bindings_from(
449 &self,
450 patterns: &[LinkPattern],
451 bindings: BTreeMap<String, String>,
452 ) -> Option<BTreeMap<String, String>> {
453 let Some((pattern, remaining)) = patterns.split_first() else {
454 return Some(bindings);
455 };
456 for link in &self.links {
457 let mut candidate = bindings.clone();
458 if pattern_matches_link(pattern, link, &mut candidate) {
459 if let Some(found) = self.find_bindings_from(remaining, candidate) {
460 return Some(found);
461 }
462 }
463 }
464 None
465 }
466
467 fn canonical_links(&self) -> String {
468 let mut out = String::new();
469 for link in &self.links {
470 let _ = write!(out, "{}->{};", link.from, link.to);
471 }
472 out
473 }
474}
475
476#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct SubstitutionTrace {
479 pub id: String,
480 pub sequence: usize,
481 pub rule_id: String,
482 pub event: CrudEvent,
483 pub bindings: BTreeMap<String, String>,
484 pub removed: Vec<SubstitutionLink>,
485 pub added: Vec<SubstitutionLink>,
486}
487
488impl SubstitutionTrace {
489 fn new(
490 sequence: usize,
491 rule_id: &str,
492 event: CrudEvent,
493 bindings: BTreeMap<String, String>,
494 removed: Vec<SubstitutionLink>,
495 added: Vec<SubstitutionLink>,
496 ) -> Self {
497 let id = stable_id(
498 "substitution_trace",
499 &canonical_trace(sequence, rule_id, event, &bindings, &removed, &added),
500 );
501 Self {
502 id,
503 sequence,
504 rule_id: rule_id.to_owned(),
505 event,
506 bindings,
507 removed,
508 added,
509 }
510 }
511}
512
513#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct SubstitutionTraceReport {
516 pub event: CrudEvent,
517 pub traces: Vec<SubstitutionTrace>,
518 pub terminated_by_guard: bool,
519}
520
521impl SubstitutionTraceReport {
522 #[must_use]
523 pub const fn new(event: CrudEvent) -> Self {
524 Self {
525 event,
526 traces: Vec::new(),
527 terminated_by_guard: false,
528 }
529 }
530
531 #[must_use]
532 pub const fn applied_count(&self) -> usize {
533 self.traces.len()
534 }
535
536 #[must_use]
537 pub fn links_notation(&self) -> String {
538 let mut out = String::new();
539 push_lino_node(&mut out, 0, "substitution_trace_report", None);
540 push_lino_node(
541 &mut out,
542 2,
543 "id",
544 Some(&stable_id("substitution_trace_report", &self.canonical())),
545 );
546 push_lino_node(&mut out, 2, "event", Some(self.event.as_str()));
547 push_lino_node(
548 &mut out,
549 2,
550 "terminated_by_guard",
551 Some(if self.terminated_by_guard {
552 "true"
553 } else {
554 "false"
555 }),
556 );
557 for trace in &self.traces {
558 push_lino_node(&mut out, 2, "trace", Some(&trace.id));
559 push_lino_node(&mut out, 4, "sequence", Some(&trace.sequence.to_string()));
560 push_lino_node(&mut out, 4, "rule_id", Some(&trace.rule_id));
561 push_lino_node(&mut out, 4, "event", Some(trace.event.as_str()));
562 for (name, value) in &trace.bindings {
563 push_lino_node(&mut out, 4, "binding", Some(&format!("{name}={value}")));
564 }
565 for link in &trace.removed {
566 push_lino_node(&mut out, 4, "removed", Some(&link.pattern_text()));
567 }
568 for link in &trace.added {
569 push_lino_node(&mut out, 4, "added", Some(&link.pattern_text()));
570 }
571 }
572 out.trim_end().to_owned()
573 }
574
575 #[must_use]
576 pub fn trace_link_records(&self) -> Vec<LinkRecord> {
577 self.traces.iter().map(trace_link_record).collect()
578 }
579
580 fn canonical(&self) -> String {
581 let mut out = format!(
582 "event={};terminated={};",
583 self.event.as_str(),
584 self.terminated_by_guard
585 );
586 for trace in &self.traces {
587 out.push_str(&trace.id);
588 out.push(';');
589 }
590 out
591 }
592}
593
594#[derive(Debug, Clone, PartialEq, Eq)]
596pub enum SubstitutionRuleError {
597 EmptyDocument,
598 IllFormedLinksNotation(String),
599 NotSubstitutionRules(String),
600 MissingReplacement(String),
601 InvalidPattern(String),
602 InvalidEvent(String),
603}
604
605impl fmt::Display for SubstitutionRuleError {
606 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
607 match self {
608 Self::EmptyDocument => formatter.write_str("substitution rule document is empty"),
609 Self::IllFormedLinksNotation(message) => {
610 write!(
611 formatter,
612 "ill-formed substitution rule Links Notation: {message}"
613 )
614 }
615 Self::NotSubstitutionRules(root) => {
616 write!(formatter, "{root} is not a substitution_rules document")
617 }
618 Self::MissingReplacement(rule_id) => {
619 write!(formatter, "rule {rule_id} has replace without with")
620 }
621 Self::InvalidPattern(message) => write!(formatter, "invalid link pattern: {message}"),
622 Self::InvalidEvent(event) => write!(formatter, "invalid CRUD event: {event}"),
623 }
624 }
625}
626
627impl Error for SubstitutionRuleError {}
628
629fn parse_rule(node: &LinoNode) -> Result<SubstitutionRule, SubstitutionRuleError> {
630 let mut order = 0;
631 let mut events = Vec::new();
632 let mut conditions = Vec::new();
633 let mut actions = Vec::new();
634
635 for child in &node.children {
636 match child.name.as_str() {
637 "order" => {
638 order = child.id.trim().parse::<i64>().unwrap_or(0);
639 }
640 "event" => {
641 events.push(CrudEvent::parse(&child.id)?);
642 }
643 "when" => {
644 conditions.push(LinkPattern::parse(&child.id)?);
645 }
646 "replace" => {
647 let add = child
648 .children
649 .iter()
650 .filter(|grandchild| grandchild.name == "with")
651 .map(|grandchild| LinkPattern::parse(&grandchild.id))
652 .collect::<Result<Vec<_>, _>>()?;
653 if add.is_empty() {
654 return Err(SubstitutionRuleError::MissingReplacement(node.id.clone()));
655 }
656 actions.push(SubstitutionAction {
657 remove: LinkPattern::parse(&child.id)?,
658 add,
659 });
660 }
661 _ => {}
662 }
663 }
664 if events.is_empty() {
665 events.push(CrudEvent::Manual);
666 }
667 Ok(SubstitutionRule {
668 id: node.id.clone(),
669 order,
670 events,
671 conditions,
672 actions,
673 })
674}
675
676fn validate_variable(variable: &str, raw: &str) -> Result<(), SubstitutionRuleError> {
677 if variable.is_empty()
678 || !variable
679 .chars()
680 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
681 {
682 return Err(SubstitutionRuleError::InvalidPattern(format!(
683 "invalid variable in `{raw}`"
684 )));
685 }
686 Ok(())
687}
688
689fn pattern_matches_link(
690 pattern: &LinkPattern,
691 link: &SubstitutionLink,
692 bindings: &mut BTreeMap<String, String>,
693) -> bool {
694 node_matches(&pattern.from, &link.from, bindings)
695 && node_matches(&pattern.to, &link.to, bindings)
696}
697
698fn node_matches(
699 pattern: &PatternNode,
700 value: &str,
701 bindings: &mut BTreeMap<String, String>,
702) -> bool {
703 match pattern {
704 PatternNode::Literal(literal) => literal == value,
705 PatternNode::Variable(variable) => bind_variable(bindings, variable, value),
706 PatternNode::PrefixVariable { prefix, variable } => {
707 let Some(captured) = value.strip_prefix(prefix) else {
708 return false;
709 };
710 bind_variable(bindings, variable, captured)
711 }
712 }
713}
714
715fn bind_variable(bindings: &mut BTreeMap<String, String>, variable: &str, value: &str) -> bool {
716 if let Some(existing) = bindings.get(variable) {
717 return existing == value;
718 }
719 bindings.insert(variable.to_owned(), value.to_owned());
720 true
721}
722
723fn canonical_trace(
724 sequence: usize,
725 rule_id: &str,
726 event: CrudEvent,
727 bindings: &BTreeMap<String, String>,
728 removed: &[SubstitutionLink],
729 added: &[SubstitutionLink],
730) -> String {
731 let mut out = format!("{sequence}:{}:{rule_id};", event.as_str());
732 for (key, value) in bindings {
733 let _ = write!(out, "binding:{key}={value};");
734 }
735 for link in removed {
736 let _ = write!(out, "removed:{};", link.pattern_text());
737 }
738 for link in added {
739 let _ = write!(out, "added:{};", link.pattern_text());
740 }
741 out
742}
743
744fn trace_link_record(trace: &SubstitutionTrace) -> LinkRecord {
745 let mut links = Vec::new();
746 push_doublet(&mut links, &trace.id, "Type");
747 push_doublet(&mut links, "Type", "SubstitutionTraceLink");
748 push_doublet(&mut links, "SubstitutionTraceLink", "SubType");
749 push_doublet(&mut links, "SubType", trace.event.as_str());
750 push_doublet(&mut links, trace.event.as_str(), "Value");
751 push_doublet(&mut links, &trace.id, &trace.rule_id);
752 push_doublet(
753 &mut links,
754 &trace.id,
755 &format!("schema_version:{KNOWLEDGE_SCHEMA_VERSION}"),
756 );
757 push_field(&mut links, &trace.id, "rule_id", &trace.rule_id);
758 push_field(&mut links, &trace.id, "event", trace.event.as_str());
759 push_field(
760 &mut links,
761 &trace.id,
762 "sequence",
763 &trace.sequence.to_string(),
764 );
765 for (name, value) in &trace.bindings {
766 push_field(&mut links, &trace.id, "binding", &format!("{name}={value}"));
767 }
768 for link in &trace.removed {
769 push_field(&mut links, &trace.id, "removed", &link.pattern_text());
770 }
771 for link in &trace.added {
772 push_field(&mut links, &trace.id, "added", &link.pattern_text());
773 }
774 LinkRecord {
775 stable_id: trace.id.clone(),
776 schema_version: String::from(KNOWLEDGE_SCHEMA_VERSION),
777 record_type: String::from("SubstitutionTraceLink"),
778 source_id: trace.rule_id.clone(),
779 links,
780 }
781}
782
783fn push_field(links: &mut Vec<DoubletLink>, record_id: &str, key: &str, value: &str) {
784 let field = format!("field:{key}");
785 let field_value = format!("value:{value}");
786 push_doublet(links, record_id, &field);
787 push_doublet(links, &field, &field_value);
788}
789
790fn push_doublet(links: &mut Vec<DoubletLink>, from: &str, to: &str) {
791 links.push(DoubletLink {
792 index: stable_id("doublet", &format!("{from}->{to}")),
793 from: from.to_owned(),
794 to: to.to_owned(),
795 });
796}