Skip to main content

link_cli/
persistent_transformations.rs

1//! Persistent transformation triggers.
2//!
3//! A *trigger* is a stored LiNo substitution query that the CLI replays after
4//! every write, turning a one-off transformation into a standing rule. This is
5//! the Rust port of
6//! `Foundation.Data.Doublets.Cli.PersistentTransformationDecorator`, and it
7//! keeps the same on-disk shape so the two implementations can read each
8//! other's trigger databases:
9//!
10//! ```text
11//! (Always ((Condition <condition text>) (Substitution <substitution text>)))
12//! (Once   ((Condition <condition text>) (Substitution <substitution text>)))
13//! ```
14//!
15//! `Condition`, `Substitution`, `Type`, `Trigger`, `Once` and `Always` are
16//! named points; the condition and substitution texts are named points too,
17//! whose names carry the [`INTERNAL_NAME_PREFIX`] so they cannot collide with
18//! user-visible names.
19//!
20//! # Where the triggers live
21//!
22//! [`TriggerStore`] decides that: [`TriggerStore::Sidecar`] keeps them in a
23//! companion database (`<db>.triggers.links` by default, see
24//! [`make_triggers_database_filename`]), while [`TriggerStore::Embedded`]
25//! stores them in the decorated database itself — the `--embed-triggers` mode.
26//!
27//! # Extension points
28//!
29//! The decorator is generic over any [`NamedTypeLinks`], so it composes with
30//! the plain store, the transactions layer and the version-control layer
31//! alike, and an embedder can stack it wherever it wants in its own chain. The
32//! parsing (
33//! [`PersistentTransformationQuery`]), the stored form
34//! ([`PersistentTransformation`]) and the store selection ([`TriggerStore`])
35//! are all public so custom CLIs can inspect, migrate or generate triggers
36//! without going through this decorator at all.
37
38use std::collections::HashMap;
39use std::fmt;
40use std::path::{Path, PathBuf};
41
42use anyhow::{anyhow, Result};
43
44use crate::link::Link;
45use crate::link_storage::ChangeObserver;
46use crate::lino_link::LinoLink;
47use crate::named_type_links::{escape_lino_reference, NamedTypeLinks};
48use crate::named_types::NamedTypesDecorator;
49use crate::parser::Parser;
50use crate::query_processor::QueryProcessor;
51
52/// Prefix of the internal names used for stored condition and substitution
53/// texts. It keeps trigger bookkeeping distinguishable from user names even in
54/// [`TriggerStore::Embedded`] mode, where both share one namespace.
55pub const INTERNAL_NAME_PREFIX: &str = "__persistent_transformation:";
56
57const MISSING_PARTS: &str =
58    "Persistent transformation query must contain a condition and a substitution.";
59
60/// How long a stored trigger lives.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum PersistentTransformationKind {
63    /// Applied once, then removed as soon as an application produced changes.
64    Once,
65    /// Applied after every write, indefinitely.
66    Always,
67}
68
69impl fmt::Display for PersistentTransformationKind {
70    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        let text = match self {
72            Self::Once => "Once",
73            Self::Always => "Always",
74        };
75        formatter.write_str(text)
76    }
77}
78
79/// A trigger as it is stored in a links database.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct PersistentTransformation {
82    /// Address of the `(kind payload)` link that roots this trigger.
83    pub root: u32,
84    pub kind: PersistentTransformationKind,
85    /// The condition (left) half of the substitution query.
86    pub condition: String,
87    /// The substitution (right) half of the substitution query.
88    pub substitution: String,
89}
90
91impl PersistentTransformation {
92    /// The query that gets replayed after every write.
93    pub fn query(&self) -> String {
94        format!("({} {})", self.condition, self.substitution)
95    }
96}
97
98/// A trigger query split into its condition and substitution halves.
99///
100/// Both halves are re-formatted from the parse tree rather than kept as raw
101/// input, so two spellings of the same query (`((1: 1 1)) ((1: 1 2))` and
102/// `(((1: 1 1)) ((1: 1 2)))`) normalise to the same stored text and therefore
103/// to the same trigger.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct PersistentTransformationQuery {
106    pub condition: String,
107    pub substitution: String,
108}
109
110impl PersistentTransformationQuery {
111    /// Parses `query` into its two halves.
112    ///
113    /// Both the wrapped form `((condition) (substitution))` and the bare form
114    /// `(condition) (substitution)` are accepted, matching the C# parser.
115    pub fn parse(query: &str) -> Result<Self> {
116        let parsed = Parser::new().parse(query)?;
117        let outer = parsed.first().ok_or_else(|| anyhow!(MISSING_PARTS))?;
118
119        let (condition, substitution) = match outer.values.as_deref() {
120            Some(values) if values.len() >= 2 => (&values[0], &values[1]),
121            _ if parsed.len() >= 2 => (&parsed[0], &parsed[1]),
122            _ => return Err(anyhow!(MISSING_PARTS)),
123        };
124
125        Ok(Self {
126            condition: format_lino(condition),
127            substitution: format_lino(substitution),
128        })
129    }
130
131    /// The normalised `(condition substitution)` query text.
132    pub fn query(&self) -> String {
133        format!("({} {})", self.condition, self.substitution)
134    }
135}
136
137/// Renders a parsed LiNo link back to source text.
138///
139/// Mirrors `PersistentTransformationQuery.Format` in C#: a link without values
140/// is just its (escaped) identifier, a link without an identifier is
141/// `(values)`, and a link with both is `(id: values)`.
142fn format_lino(link: &LinoLink) -> String {
143    let values = link.values.as_deref().unwrap_or(&[]);
144    let id = link.id.as_deref().unwrap_or_default();
145
146    if values.is_empty() {
147        return if id.is_empty() {
148            "()".to_string()
149        } else {
150            escape_lino_reference(id)
151        };
152    }
153
154    let rendered = values.iter().map(format_lino).collect::<Vec<_>>().join(" ");
155
156    if id.is_empty() {
157        format!("({rendered})")
158    } else {
159        format!("({}: {})", escape_lino_reference(id), rendered)
160    }
161}
162
163/// Conventional sidecar filename for the trigger store: `<db>.triggers.links`.
164pub fn make_triggers_database_filename<P: AsRef<Path>>(database_filename: P) -> PathBuf {
165    let path = database_filename.as_ref();
166    let stem = path
167        .file_stem()
168        .and_then(|stem| stem.to_str())
169        .unwrap_or_default();
170    let name = format!("{stem}.triggers.links");
171    match path.parent() {
172        Some(parent) if !parent.as_os_str().is_empty() => parent.join(name),
173        _ => PathBuf::from(name),
174    }
175}
176
177/// Where a [`PersistentTransformationDecorator`] keeps its triggers.
178pub enum TriggerStore {
179    /// In the decorated database itself (`--embed-triggers`).
180    Embedded,
181    /// In a separate companion database (the default).
182    Sidecar(Box<NamedTypesDecorator>),
183}
184
185impl TriggerStore {
186    /// Opens a sidecar store at `path`.
187    pub fn sidecar<P: AsRef<Path>>(path: P, trace: bool) -> Result<Self> {
188        Ok(Self::Sidecar(Box::new(NamedTypesDecorator::new(
189            path, trace,
190        )?)))
191    }
192}
193
194/// The schema points a trigger is built from.
195///
196/// `Type` and `Trigger` are part of the stored schema too, but they only
197/// classify the other points and are never dereferenced while reading or
198/// writing a trigger, so they are not carried here.
199#[derive(Debug, Clone, Copy)]
200struct TriggerSchema {
201    once: u32,
202    always: u32,
203    condition: u32,
204    substitution: u32,
205}
206
207const SCHEMA_NAMES: [&str; 6] = [
208    "Type",
209    "Trigger",
210    "Once",
211    "Always",
212    "Condition",
213    "Substitution",
214];
215
216/// Creates the schema points and the links that relate them, and returns them.
217fn ensure_schema<L: NamedTypeLinks + ?Sized>(links: &mut L) -> Result<TriggerSchema> {
218    let r#type = links.get_or_create_named("Type")?;
219    let trigger = links.get_or_create_named("Trigger")?;
220    let once = links.get_or_create_named("Once")?;
221    let always = links.get_or_create_named("Always")?;
222    let condition = links.get_or_create_named("Condition")?;
223    let substitution = links.get_or_create_named("Substitution")?;
224
225    links.get_or_create(r#type, trigger);
226    links.get_or_create(trigger, once);
227    links.get_or_create(trigger, always);
228    links.get_or_create(r#type, condition);
229    links.get_or_create(r#type, substitution);
230
231    Ok(TriggerSchema {
232        once,
233        always,
234        condition,
235        substitution,
236    })
237}
238
239/// Reads the schema without creating anything; `None` when any of the six
240/// schema points is missing, i.e. when no trigger has ever been stored in
241/// `links`.
242fn try_get_schema<L: NamedTypeLinks + ?Sized>(links: &mut L) -> Result<Option<TriggerSchema>> {
243    let mut ids = [0u32; 6];
244    for (slot, name) in ids.iter_mut().zip(SCHEMA_NAMES) {
245        match links.get_by_name(name)? {
246            Some(id) => *slot = id,
247            None => return Ok(None),
248        }
249    }
250
251    Ok(Some(TriggerSchema {
252        once: ids[2],
253        always: ids[3],
254        condition: ids[4],
255        substitution: ids[5],
256    }))
257}
258
259/// Every well-formed trigger in `links`, ordered by root address.
260fn triggers_in<L: NamedTypeLinks + ?Sized>(links: &mut L) -> Result<Vec<PersistentTransformation>> {
261    let Some(schema) = try_get_schema(links)? else {
262        return Ok(Vec::new());
263    };
264
265    let mut all = links.all_links();
266    all.sort_by_key(|link| link.index);
267    let by_index: HashMap<u32, Link> = all.iter().map(|link| (link.index, *link)).collect();
268
269    let mut triggers = Vec::new();
270    for link in &all {
271        let kind = if link.source == schema.always {
272            PersistentTransformationKind::Always
273        } else if link.source == schema.once {
274            PersistentTransformationKind::Once
275        } else {
276            continue;
277        };
278
279        let Some(payload) = by_index.get(&link.target) else {
280            continue;
281        };
282        let (Some(condition_record), Some(substitution_record)) =
283            (by_index.get(&payload.source), by_index.get(&payload.target))
284        else {
285            continue;
286        };
287        if condition_record.source != schema.condition
288            || substitution_record.source != schema.substitution
289        {
290            continue;
291        }
292
293        let condition = links.get_name(condition_record.target)?;
294        let substitution = links.get_name(substitution_record.target)?;
295        let (Some(condition), Some(substitution)) = (
296            decode_text_name(condition.as_deref(), "condition"),
297            decode_text_name(substitution.as_deref(), "substitution"),
298        ) else {
299            continue;
300        };
301
302        triggers.push(PersistentTransformation {
303            root: link.index,
304            kind,
305            condition,
306            substitution,
307        });
308    }
309
310    Ok(triggers)
311}
312
313/// Writes `parsed` into `links` as a trigger of `kind`, returning its root.
314///
315/// Every part is created through `get_or_create`, so storing the same trigger
316/// twice is idempotent and yields the same root.
317fn store_trigger_in<L: NamedTypeLinks + ?Sized>(
318    links: &mut L,
319    kind: PersistentTransformationKind,
320    parsed: &PersistentTransformationQuery,
321) -> Result<u32> {
322    let schema = ensure_schema(links)?;
323    let condition_text = links.get_or_create_named(&condition_text_name(&parsed.condition))?;
324    let substitution_text =
325        links.get_or_create_named(&substitution_text_name(&parsed.substitution))?;
326    let condition_record = links.get_or_create(schema.condition, condition_text);
327    let substitution_record = links.get_or_create(schema.substitution, substitution_text);
328    let payload = links.get_or_create(condition_record, substitution_record);
329    let trigger_type = match kind {
330        PersistentTransformationKind::Always => schema.always,
331        PersistentTransformationKind::Once => schema.once,
332    };
333    Ok(links.get_or_create(trigger_type, payload))
334}
335
336/// Deletes the `(kind payload)` root link, leaving the shared schema and text
337/// points in place — exactly like `DeleteTriggerRoot` in C#.
338fn delete_trigger_root<L: NamedTypeLinks + ?Sized>(links: &mut L, root: u32) -> Result<bool> {
339    if !links.exists(root) {
340        return Ok(false);
341    }
342    links.delete(root)?;
343    Ok(true)
344}
345
346fn condition_text_name(condition: &str) -> String {
347    format!("{INTERNAL_NAME_PREFIX}condition:{condition}")
348}
349
350fn substitution_text_name(substitution: &str) -> String {
351    format!("{INTERNAL_NAME_PREFIX}substitution:{substitution}")
352}
353
354fn decode_text_name(name: Option<&str>, part: &str) -> Option<String> {
355    let prefix = format!("{INTERNAL_NAME_PREFIX}{part}:");
356    name?.strip_prefix(&prefix).map(str::to_string)
357}
358
359/// Runs `$call` against whichever store holds the triggers.
360///
361/// [`NamedTypeLinks`] has generic default methods and is therefore not object
362/// safe, so the two stores cannot be unified behind a trait object; the macro
363/// picks the branch instead. The `Embedded` arm borrows `links` while the
364/// scrutinee borrows `triggers` — disjoint fields, which the borrow checker
365/// accepts.
366macro_rules! on_trigger_links {
367    ($self:expr, $call:ident($($arg:expr),* $(,)?)) => {
368        match $self.triggers {
369            TriggerStore::Sidecar(ref mut store) => $call(store.as_mut() $(, $arg)*),
370            TriggerStore::Embedded => $call(&mut $self.links $(, $arg)*),
371        }
372    };
373}
374
375/// Replays stored triggers after every write that goes through it.
376///
377/// Wrap it around any [`NamedTypeLinks`] — the bare store, the transactions
378/// decorator, the version-control decorator, or a custom one.
379pub struct PersistentTransformationDecorator<L: NamedTypeLinks> {
380    links: L,
381    triggers: TriggerStore,
382    trace: bool,
383    applying_triggers: bool,
384    suppress_triggers: bool,
385    auto_create_missing_references: bool,
386    /// First failure raised while applying triggers from an infallible write.
387    ///
388    /// [`NamedTypeLinks::create`], `ensure_created` and `get_or_create` cannot
389    /// report an error, so a failing trigger is parked here and surfaced by the
390    /// next fallible operation — at the latest by
391    /// [`save`](NamedTypeLinks::save), which the CLI always calls.
392    pending_error: Option<anyhow::Error>,
393}
394
395impl<L: NamedTypeLinks> PersistentTransformationDecorator<L> {
396    pub fn new(links: L, triggers: TriggerStore, trace: bool) -> Self {
397        Self {
398            links,
399            triggers,
400            trace,
401            applying_triggers: false,
402            suppress_triggers: false,
403            auto_create_missing_references: false,
404            pending_error: None,
405        }
406    }
407
408    /// Keeps the triggers in the decorated database itself.
409    pub fn embedded(links: L, trace: bool) -> Self {
410        Self::new(links, TriggerStore::Embedded, trace)
411    }
412
413    /// Keeps the triggers in `trigger_links`.
414    pub fn with_sidecar(links: L, trigger_links: NamedTypesDecorator, trace: bool) -> Self {
415        Self::new(links, TriggerStore::Sidecar(Box::new(trigger_links)), trace)
416    }
417
418    /// Whether replayed triggers may create missing references as points.
419    pub fn with_auto_create_missing_references(mut self, enabled: bool) -> Self {
420        self.auto_create_missing_references = enabled;
421        self
422    }
423
424    pub fn auto_create_missing_references(&self) -> bool {
425        self.auto_create_missing_references
426    }
427
428    pub fn set_auto_create_missing_references(&mut self, enabled: bool) {
429        self.auto_create_missing_references = enabled;
430    }
431
432    pub fn inner(&self) -> &L {
433        &self.links
434    }
435
436    pub fn inner_mut(&mut self) -> &mut L {
437        &mut self.links
438    }
439
440    pub fn trigger_store(&self) -> &TriggerStore {
441        &self.triggers
442    }
443
444    pub fn trigger_store_mut(&mut self) -> &mut TriggerStore {
445        &mut self.triggers
446    }
447
448    /// Gives the decorated links and the trigger store back.
449    pub fn into_parts(self) -> (L, TriggerStore) {
450        (self.links, self.triggers)
451    }
452
453    /// Stores `query` as a trigger of `kind` and returns its root address.
454    pub fn store_trigger(
455        &mut self,
456        kind: PersistentTransformationKind,
457        query: &str,
458    ) -> Result<u32> {
459        let parsed = PersistentTransformationQuery::parse(query)?;
460        let root = self.without_trigger_application(|this| {
461            on_trigger_links!(this, store_trigger_in(kind, &parsed))
462        })?;
463        self.trace_msg(&format!(
464            "Stored {kind} trigger #{root}: {}",
465            parsed.query()
466        ));
467        Ok(root)
468    }
469
470    /// Removes every stored trigger whose query equals `query`, and returns how
471    /// many were removed.
472    pub fn remove_triggers(&mut self, query: &str) -> Result<usize> {
473        let parsed = PersistentTransformationQuery::parse(query)?;
474        self.without_trigger_application(|this| {
475            let matching: Vec<u32> = this
476                .triggers()?
477                .into_iter()
478                .filter(|trigger| {
479                    trigger.condition == parsed.condition
480                        && trigger.substitution == parsed.substitution
481                })
482                .map(|trigger| trigger.root)
483                .collect();
484
485            for root in &matching {
486                on_trigger_links!(this, delete_trigger_root(*root))?;
487                this.trace_msg(&format!("Deleted trigger #{root}"));
488            }
489
490            Ok(matching.len())
491        })
492    }
493
494    /// Every stored trigger, ordered by root address.
495    pub fn triggers(&mut self) -> Result<Vec<PersistentTransformation>> {
496        on_trigger_links!(self, triggers_in())
497    }
498
499    /// Runs `action` with trigger application suppressed, restoring the
500    /// previous setting afterwards. This is what keeps trigger bookkeeping from
501    /// triggering itself.
502    fn without_trigger_application<R>(&mut self, action: impl FnOnce(&mut Self) -> R) -> R {
503        let previous = self.suppress_triggers;
504        self.suppress_triggers = true;
505        let result = action(self);
506        self.suppress_triggers = previous;
507        result
508    }
509
510    /// Records a trigger failure raised by an infallible write.
511    fn after_write(&mut self) {
512        if let Err(error) = self.apply_triggers_after_operation() {
513            if self.pending_error.is_none() {
514                self.pending_error = Some(error);
515            }
516        }
517    }
518
519    /// Surfaces (and clears) a failure parked by [`after_write`].
520    fn take_pending_error(&mut self) -> Result<()> {
521        match self.pending_error.take() {
522            Some(error) => Err(error),
523            None => Ok(()),
524        }
525    }
526
527    fn apply_triggers_after_operation(&mut self) -> Result<()> {
528        if self.suppress_triggers || self.applying_triggers {
529            return Ok(());
530        }
531
532        let triggers = self.triggers()?;
533        if triggers.is_empty() {
534            return Ok(());
535        }
536
537        self.applying_triggers = true;
538        let outcome = self.apply_triggers(&triggers);
539        self.applying_triggers = false;
540        outcome
541    }
542
543    fn apply_triggers(&mut self, triggers: &[PersistentTransformation]) -> Result<()> {
544        let processor = QueryProcessor::new(self.trace)
545            .with_auto_create_missing_references(self.auto_create_missing_references);
546
547        for trigger in triggers {
548            let changes = processor.process_query(self, &trigger.query())?;
549            if changes.is_empty() || trigger.kind != PersistentTransformationKind::Once {
550                continue;
551            }
552
553            let root = trigger.root;
554            self.without_trigger_application(|this| {
555                on_trigger_links!(this, delete_trigger_root(root))
556            })?;
557            self.trace_msg(&format!("Deleted trigger #{root}"));
558        }
559
560        Ok(())
561    }
562
563    fn trace_msg(&self, message: &str) {
564        if self.trace {
565            println!("[PersistentTransformation] {message}");
566        }
567    }
568}
569
570impl<L: NamedTypeLinks> NamedTypeLinks for PersistentTransformationDecorator<L> {
571    fn create(&mut self, source: u32, target: u32) -> u32 {
572        let index = self.links.create(source, target);
573        self.after_write();
574        index
575    }
576
577    /// Only a call that really allocates a link counts as a write.
578    ///
579    /// C# hooks the raw `ILinks.Create`, which `EnsureCreated` reaches only for
580    /// addresses the store does not have yet; an `EnsureCreated` for an existing
581    /// address performs no operation and therefore fires no trigger. Checking
582    /// [`exists`](NamedTypeLinks::exists) first reproduces that: without it a
583    /// no-op query such as `() ((1: 1 1))` over an already existing point would
584    /// replay every trigger in Rust and none in C#.
585    fn ensure_created(&mut self, id: u32) -> u32 {
586        let existed = self.links.exists(id);
587        let index = self.links.ensure_created(id);
588        if !existed {
589            self.after_write();
590        }
591        index
592    }
593
594    fn get_link(&mut self, id: u32) -> Option<Link> {
595        self.links.get_link(id)
596    }
597
598    fn exists(&mut self, id: u32) -> bool {
599        self.links.exists(id)
600    }
601
602    fn update(&mut self, id: u32, source: u32, target: u32) -> Result<Link> {
603        let link = self.links.update(id, source, target)?;
604        self.apply_triggers_after_operation()?;
605        self.take_pending_error()?;
606        Ok(link)
607    }
608
609    fn delete(&mut self, id: u32) -> Result<Link> {
610        let link = self.links.delete(id)?;
611        self.apply_triggers_after_operation()?;
612        self.take_pending_error()?;
613        Ok(link)
614    }
615
616    fn delete_observed(&mut self, id: u32, observer: ChangeObserver<'_>) -> Result<Link> {
617        let link = self.links.delete_observed(id, observer)?;
618        self.apply_triggers_after_operation()?;
619        self.take_pending_error()?;
620        Ok(link)
621    }
622
623    fn all_links(&mut self) -> Vec<Link> {
624        self.links.all_links()
625    }
626
627    fn search(&mut self, source: u32, target: u32) -> Option<u32> {
628        self.links.search(source, target)
629    }
630
631    /// Fires triggers only when the pair had to be created — see
632    /// [`ensure_created`](Self::ensure_created) for why.
633    fn get_or_create(&mut self, source: u32, target: u32) -> u32 {
634        if let Some(index) = self.links.search(source, target) {
635            return index;
636        }
637        let index = self.links.get_or_create(source, target);
638        self.after_write();
639        index
640    }
641
642    fn get_name(&mut self, id: u32) -> Result<Option<String>> {
643        self.links.get_name(id)
644    }
645
646    fn set_name(&mut self, id: u32, name: &str) -> Result<u32> {
647        self.links.set_name(id, name)
648    }
649
650    fn get_by_name(&mut self, name: &str) -> Result<Option<u32>> {
651        self.links.get_by_name(name)
652    }
653
654    fn remove_name(&mut self, id: u32) -> Result<()> {
655        self.links.remove_name(id)
656    }
657
658    fn save(&mut self) -> Result<()> {
659        self.take_pending_error()?;
660        self.links.save()?;
661        if let TriggerStore::Sidecar(store) = &mut self.triggers {
662            store.save()?;
663        }
664        Ok(())
665    }
666}