kimun_notes/components/search_list/
resolving.rs1use 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
21pub enum Unresolvable {
22 Empty,
25 AsEmptyQuery,
28}
29
30pub 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 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 #[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 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 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 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 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 let (_rows, seen) = run("widget <", QueryContext::default(), Unresolvable::Empty).await;
181 assert_eq!(seen, vec!["widget <".to_string()]);
182 }
183}