Skip to main content

kimun_notes/components/search_list/
resolving.rs

1//! `ResolvingRowSource`: the one adapter that resolves a query template's
2//! variables before any inner [`RowSource`] sees it. It reads a fresh
3//! [`QueryContext`] per load (so a panel whose open note changes between loads
4//! resolves against the current note), substitutes `{note}` and the
5//! bare-operator sugar, and applies a fallback when the template needs a note
6//! none is available. Inner sources speak only resolved queries and never
7//! import the query-variable logic. See CONTEXT.md "Resolving row source".
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12
13use super::{Emit, RowSource, SearchRow};
14use crate::components::query_vars::{QueryContext, query_is_unresolvable, resolve_query};
15
16/// What to do when a template is purely note-dependent but no note is available
17/// to resolve it (the startup state, or a browser launched from the root).
18/// Running the bare prefix against core is a dead-end empty round-trip, so each
19/// surface picks how to degrade instead.
20#[derive(Clone, Copy, PartialEq, Eq, Debug)]
21pub enum Unresolvable {
22    /// Emit nothing (the Query panel: an unresolvable backlinks query has no
23    /// results to show yet).
24    Empty,
25    /// Run the inner source as if the query were empty (the note browser: fall
26    /// back to its recent-notes view).
27    AsEmptyQuery,
28}
29
30/// A [`RowSource`] that resolves query variables against a per-load
31/// [`QueryContext`] before delegating to `inner`. Generic over the row type, so
32/// the same adapter serves every query-variable surface.
33pub struct ResolvingRowSource<R: SearchRow> {
34    inner: Arc<dyn RowSource<R>>,
35    ctx: Arc<dyn Fn() -> QueryContext + Send + Sync>,
36    on_unresolvable: Unresolvable,
37}
38
39impl<R: SearchRow> ResolvingRowSource<R> {
40    pub fn new(
41        inner: Arc<dyn RowSource<R>>,
42        ctx: impl Fn() -> QueryContext + Send + Sync + 'static,
43        on_unresolvable: Unresolvable,
44    ) -> Self {
45        Self {
46            inner,
47            ctx: Arc::new(ctx),
48            on_unresolvable,
49        }
50    }
51}
52
53#[async_trait]
54impl<R: SearchRow> RowSource<R> for ResolvingRowSource<R> {
55    async fn load(&self, query: &str, emit: Emit<R>) {
56        let ctx = (self.ctx)();
57        if query_is_unresolvable(query, &ctx) {
58            match self.on_unresolvable {
59                Unresolvable::Empty => emit.replace(Vec::new()),
60                Unresolvable::AsEmptyQuery => self.inner.load("", emit).await,
61            }
62            return;
63        }
64        self.inner.load(&resolve_query(query, &ctx), emit).await;
65    }
66
67    // The leading-row affordance ("Create: …") and the reload strategy are the
68    // inner source's policy — forward them so wrapping is transparent. Note the
69    // leading row sees the RAW template (what the user typed), while `load`
70    // sees the resolved query.
71    fn leading_row(&self, query: &str) -> Option<R> {
72        self.inner.leading_row(query)
73    }
74
75    fn reload_on_query(&self) -> bool {
76        self.inner.reload_on_query()
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::components::events::redraw_callback;
84    use crate::components::search_list::SearchList;
85    use crate::settings::icons::Icons;
86    use crate::settings::themes::Theme;
87    use kimun_core::nfs::VaultPath;
88    use ratatui::widgets::ListItem;
89    use std::sync::Mutex;
90    use tokio::sync::mpsc::unbounded_channel;
91
92    /// A row that just records the query string the inner source was asked to
93    /// load — the assertion surface for "what did the wrapper hand through?".
94    #[derive(Clone)]
95    struct EchoRow(String);
96    impl SearchRow for EchoRow {
97        fn to_list_item(&self, _: &Theme, _: &Icons, _: bool) -> ListItem<'static> {
98            ListItem::new(self.0.clone())
99        }
100        fn match_text(&self) -> Option<&str> {
101            Some(&self.0)
102        }
103    }
104
105    /// Inner source that emits one row carrying whatever query it received, and
106    /// records every query it saw (so we can assert the fallback handed `""`).
107    struct EchoSource {
108        seen: Arc<Mutex<Vec<String>>>,
109    }
110    #[async_trait]
111    impl RowSource<EchoRow> for EchoSource {
112        async fn load(&self, query: &str, emit: Emit<EchoRow>) {
113            self.seen.lock().unwrap().push(query.to_string());
114            emit.replace(vec![EchoRow(query.to_string())]);
115        }
116    }
117
118    fn note(name: &str) -> QueryContext {
119        QueryContext::with_note(Some(VaultPath::note_path_from(name)))
120    }
121
122    async fn run(initial: &str, ctx: QueryContext, on: Unresolvable) -> (Vec<String>, Vec<String>) {
123        let seen = Arc::new(Mutex::new(Vec::new()));
124        let inner = Arc::new(EchoSource { seen: seen.clone() });
125        let source = ResolvingRowSource::new(inner, move || ctx.clone(), on);
126        let (tx, _rx) = unbounded_channel();
127        let mut list = SearchList::builder(source, redraw_callback(tx))
128            .initial_query(initial)
129            .build();
130        list.poll_until_idle().await;
131        let rows = list
132            .visible_rows()
133            .iter()
134            .map(|r| r.0.clone())
135            .collect::<Vec<_>>();
136        let seen = seen.lock().unwrap().clone();
137        (rows, seen)
138    }
139
140    #[tokio::test]
141    async fn resolves_template_before_inner_sees_it() {
142        // `<{note}` with note "spec" must reach the inner source as "<spec".
143        let (_rows, seen) = run("<{note}", note("work/spec.md"), Unresolvable::Empty).await;
144        assert!(
145            seen.contains(&"<spec".to_string()),
146            "inner should see the resolved query, got {seen:?}"
147        );
148        assert!(
149            !seen.iter().any(|q| q.contains("{note}")),
150            "`{{note}}` must never reach the inner source, got {seen:?}"
151        );
152    }
153
154    #[tokio::test]
155    async fn unresolvable_empty_emits_nothing_and_skips_inner() {
156        // Purely note-dependent query, no note: Empty policy emits nothing and
157        // the inner source is never asked to load.
158        let (rows, seen) = run("<", QueryContext::default(), Unresolvable::Empty).await;
159        assert!(rows.is_empty(), "expected no rows, got {rows:?}");
160        assert!(seen.is_empty(), "inner must not be loaded, got {seen:?}");
161    }
162
163    #[tokio::test]
164    async fn unresolvable_as_empty_query_delegates_empty_to_inner() {
165        // Same query, AsEmptyQuery policy: the inner source IS loaded, with the
166        // empty query (its recent-notes fallback).
167        let (_rows, seen) = run("<", QueryContext::default(), Unresolvable::AsEmptyQuery).await;
168        assert_eq!(
169            seen,
170            vec!["".to_string()],
171            "inner should be loaded with \"\""
172        );
173    }
174
175    #[tokio::test]
176    async fn mixed_query_with_no_note_still_resolves_and_runs() {
177        // `widget <` is not unresolvable (it has a concrete term), so it runs.
178        // With no note, `{note}` resolves to an empty target, leaving the bare
179        // operator `<` in place — the inner source sees "widget <".
180        let (_rows, seen) = run("widget <", QueryContext::default(), Unresolvable::Empty).await;
181        assert_eq!(seen, vec!["widget <".to_string()]);
182    }
183}