Skip to main content

outl_exec/runtimes/
query.rs

1//! `query` runtime — declarative workspace queries as code blocks.
2//!
3//! A ` ```query ` fence runs a line-by-line declarative DSL against the
4//! workspace and returns matching blocks as **embed references**
5//! (`!((blk-XXXXXX))`), not copies. This means toggling a TODO on the
6//! original block is reflected everywhere the query result appears.
7//!
8//! Two entry points into the same engine:
9//!
10//! - **DSL string** (` ```query ` code block) — user-facing, renders embeds.
11//! - **Structured API** (`run_query_structured`) — plugin-facing, returns
12//!   typed `QueryHit` values. Exposed to JS as `outl.query({ … })`.
13//!
14//! Both converge on the same `Query` + `engine::run` pipeline.
15
16use std::path::Path;
17use std::time::Instant;
18
19use outl_md::index::WorkspaceIndex;
20
21use crate::runtime::{ExecContext, ExecError, ExecOutput, ExitStatus, OutputFormat, Runtime};
22
23// ── Public query API (used by both ```query and plugin SDK) ─────────────
24
25/// Structured query parameters — the plugin-facing API.
26///
27/// Every field is optional; an empty struct matches every block.
28/// This is the shape that `outl.query({ … })` deserialises from JS.
29#[derive(Debug, Default, Clone)]
30pub struct QueryParams {
31    /// `"todo"`, `"done"`, or `"open"` (either).
32    pub status: Option<String>,
33    /// Partial tag match (without `#`).
34    pub tag: Option<String>,
35    /// `"journal"` or `"page"`.
36    pub kind: Option<String>,
37    /// Duration like `"7d"`, `"2w"`, `"3m"`.
38    pub since: Option<String>,
39    /// Substring search (case-insensitive).
40    pub text: Option<String>,
41    /// Sort keys in priority order.
42    pub sort: Vec<String>,
43    /// Maximum number of results.
44    pub limit: Option<usize>,
45}
46
47/// One query result — structured, typed, no markdown.
48#[derive(Debug, Clone)]
49pub struct QueryHit {
50    /// Block ref handle (`blk-XXXXXX`).
51    pub handle: String,
52    /// Slug of the hosting page.
53    pub page: String,
54    /// `"todo"`, `"done"`, or `None` when the block is not a task.
55    pub status: Option<String>,
56    /// Block text with TODO/DONE prefix stripped.
57    pub text: String,
58}
59
60/// Run a query from structured parameters against the workspace at
61/// `workspace_root`. Returns sorted, limited hits.
62pub fn run_query_structured(
63    params: &QueryParams,
64    workspace_root: &Path,
65) -> Result<Vec<QueryHit>, String> {
66    let query = build_query_from_params(params)?;
67    run_query_internal(&query, workspace_root)
68}
69
70/// Run a query from a DSL string against the workspace at
71/// `workspace_root`. Returns sorted, limited hits.
72pub fn run_query_dsl(dsl: &str, workspace_root: &Path) -> Result<Vec<QueryHit>, String> {
73    let query = dsl::parse(dsl).map_err(|e| e.to_string())?;
74    run_query_internal(&query, workspace_root)
75}
76
77fn run_query_internal(query: &dsl::Query, workspace_root: &Path) -> Result<Vec<QueryHit>, String> {
78    let index = WorkspaceIndex::build(workspace_root);
79    let mut hits = engine::run(&index, query);
80    engine::sort_hits(&mut hits, &query.sort);
81    if let Some(limit) = query.limit {
82        hits.truncate(limit);
83    }
84    Ok(hits
85        .into_iter()
86        .map(|h| QueryHit {
87            handle: h.handle,
88            page: h.page_slug,
89            status: h
90                .status
91                .map(|done| if done { "done" } else { "todo" })
92                .map(String::from),
93            text: h.text,
94        })
95        .collect())
96}
97
98fn build_query_from_params(p: &QueryParams) -> Result<dsl::Query, String> {
99    let mut filters = Vec::new();
100    if let Some(s) = &p.status {
101        filters.push(dsl::Filter::Status(match s.as_str() {
102            "todo" => dsl::StatusFilter::Todo,
103            "done" => dsl::StatusFilter::Done,
104            "open" => dsl::StatusFilter::Open,
105            other => return Err(format!("invalid status '{other}' (use todo|done|open)")),
106        }));
107    }
108    if let Some(t) = &p.tag {
109        filters.push(dsl::Filter::Tag(t.clone()));
110    }
111    if let Some(k) = &p.kind {
112        filters.push(dsl::Filter::Kind(match k.as_str() {
113            "journal" => dsl::KindFilter::Journal,
114            "page" => dsl::KindFilter::Page,
115            other => return Err(format!("invalid kind '{other}' (use journal|page)")),
116        }));
117    }
118    if let Some(s) = &p.since {
119        filters.push(dsl::Filter::Since(parse_duration_pub(s)?));
120    }
121    if let Some(t) = &p.text {
122        filters.push(dsl::Filter::Text(t.clone()));
123    }
124    let mut sort = Vec::new();
125    for s in &p.sort {
126        sort.push(match s.as_str() {
127            "page" => dsl::SortKey::Page,
128            "status" => dsl::SortKey::Status,
129            "text" => dsl::SortKey::Text,
130            other => return Err(format!("invalid sort key '{other}' (use page|status|text)")),
131        });
132    }
133    Ok(dsl::Query {
134        filters,
135        sort,
136        limit: p.limit,
137    })
138}
139
140fn parse_duration_pub(v: &str) -> Result<u32, String> {
141    if v.is_empty() {
142        return Err("since requires a duration like '7d', '2w', '3m'".into());
143    }
144    let (num_str, unit) = v.split_at(v.len() - 1);
145    let n: u32 = num_str
146        .parse()
147        .map_err(|_| format!("since: invalid number in '{v}'"))?;
148    match unit {
149        "d" => Ok(n),
150        "w" => Ok(n * 7),
151        "m" => Ok(n * 30),
152        _ => Err(format!("since: unknown unit '{unit}' (use d, w, or m)")),
153    }
154}
155
156/// Query runtime — runs the DSL against the workspace on disk.
157pub struct QueryRuntime;
158
159impl Runtime for QueryRuntime {
160    fn language(&self) -> &'static str {
161        "query"
162    }
163
164    fn auto_run(&self) -> bool {
165        true
166    }
167
168    fn execute(&self, source: &str, ctx: &ExecContext) -> Result<ExecOutput, ExecError> {
169        let start = Instant::now();
170
171        let hits = run_query_dsl(source, &ctx.workspace_root).map_err(ExecError::Language)?;
172
173        let stdout = hits
174            .iter()
175            .map(|h| format!("!(({}))", h.handle))
176            .collect::<Vec<_>>()
177            .join("\n");
178
179        Ok(ExecOutput {
180            stdout,
181            stderr: String::new(),
182            duration: start.elapsed(),
183            exit: ExitStatus::Ok,
184            format: OutputFormat::Embeds,
185        })
186    }
187}
188
189/// DSL parser.
190pub(crate) mod dsl {
191    use std::fmt;
192
193    /// Parsed query.
194    #[derive(Debug, Default)]
195    pub struct Query {
196        pub filters: Vec<Filter>,
197        pub sort: Vec<SortKey>,
198        pub limit: Option<usize>,
199    }
200
201    #[derive(Debug, Clone)]
202    pub enum Filter {
203        Status(StatusFilter),
204        Tag(String),
205        Kind(KindFilter),
206        Since(u32),
207        Text(String),
208    }
209
210    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
211    pub enum StatusFilter {
212        Todo,
213        Done,
214        Open,
215    }
216
217    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
218    pub enum KindFilter {
219        Journal,
220        Page,
221    }
222
223    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
224    pub enum SortKey {
225        Page,
226        Status,
227        Text,
228    }
229
230    #[derive(Debug)]
231    pub struct ParseError {
232        /// 1-based line number where the error occurred.
233        pub line: usize,
234        /// Human-readable description.
235        pub msg: String,
236    }
237
238    impl fmt::Display for ParseError {
239        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240            write!(f, "line {}: {}", self.line, self.msg)
241        }
242    }
243
244    /// Parse a query DSL source string into a [`Query`].
245    pub fn parse(source: &str) -> Result<Query, ParseError> {
246        let mut filters = Vec::new();
247        let mut sort = Vec::new();
248        let mut limit = None;
249
250        for (i, raw) in source.lines().enumerate() {
251            let line = raw.trim();
252            if line.is_empty() || line.starts_with('#') {
253                continue;
254            }
255
256            let (key, value) = split_kv(line, i)?;
257            let key = key.trim();
258            let value = value.trim();
259
260            match key {
261                "status" => filters.push(Filter::Status(parse_status(value, i)?)),
262                "tag" => filters.push(Filter::Tag(value.to_string())),
263                "kind" => filters.push(Filter::Kind(parse_kind(value, i)?)),
264                "since" => filters.push(Filter::Since(parse_duration(value, i)?)),
265                "text" => filters.push(Filter::Text(value.to_string())),
266                "sort" => {
267                    for part in value.split(',') {
268                        sort.push(parse_sort_key(part.trim(), i)?);
269                    }
270                }
271                "limit" => {
272                    limit = Some(parse_usize(value, i)?);
273                }
274                _ => {
275                    return Err(ParseError {
276                        line: i + 1,
277                        msg: format!("unknown key: '{key}'"),
278                    });
279                }
280            }
281        }
282
283        Ok(Query {
284            filters,
285            sort,
286            limit,
287        })
288    }
289
290    fn split_kv(line: &str, line_idx: usize) -> Result<(&str, &str), ParseError> {
291        line.split_once(':').ok_or_else(|| ParseError {
292            line: line_idx + 1,
293            msg: "expected 'key: value'".into(),
294        })
295    }
296
297    fn parse_status(v: &str, line_idx: usize) -> Result<StatusFilter, ParseError> {
298        match v {
299            "todo" => Ok(StatusFilter::Todo),
300            "done" => Ok(StatusFilter::Done),
301            "open" => Ok(StatusFilter::Open),
302            _ => Err(ParseError {
303                line: line_idx + 1,
304                msg: format!("status must be 'todo', 'done', or 'open', got '{v}'"),
305            }),
306        }
307    }
308
309    fn parse_kind(v: &str, line_idx: usize) -> Result<KindFilter, ParseError> {
310        match v {
311            "journal" => Ok(KindFilter::Journal),
312            "page" => Ok(KindFilter::Page),
313            _ => Err(ParseError {
314                line: line_idx + 1,
315                msg: format!("kind must be 'journal' or 'page', got '{v}'"),
316            }),
317        }
318    }
319
320    /// Parse `Nd` / `Nw` / `Nm` into a day count.
321    fn parse_duration(v: &str, line_idx: usize) -> Result<u32, ParseError> {
322        if v.is_empty() {
323            return Err(ParseError {
324                line: line_idx + 1,
325                msg: "since requires a duration like '7d', '2w', '3m'".into(),
326            });
327        }
328        let (num_str, unit) = v.split_at(v.len() - 1);
329        let n: u32 = num_str.parse().map_err(|_| ParseError {
330            line: line_idx + 1,
331            msg: format!("since: invalid number in '{v}'"),
332        })?;
333        match unit {
334            "d" => Ok(n),
335            "w" => Ok(n * 7),
336            "m" => Ok(n * 30),
337            _ => Err(ParseError {
338                line: line_idx + 1,
339                msg: format!("since: unknown unit '{unit}' (use d, w, or m)"),
340            }),
341        }
342    }
343
344    fn parse_sort_key(v: &str, line_idx: usize) -> Result<SortKey, ParseError> {
345        match v {
346            "page" => Ok(SortKey::Page),
347            "status" => Ok(SortKey::Status),
348            "text" => Ok(SortKey::Text),
349            _ => Err(ParseError {
350                line: line_idx + 1,
351                msg: format!("sort: must be 'page', 'status', or 'text', got '{v}'"),
352            }),
353        }
354    }
355
356    fn parse_usize(v: &str, line_idx: usize) -> Result<usize, ParseError> {
357        v.parse::<usize>().map_err(|_| ParseError {
358            line: line_idx + 1,
359            msg: format!("expected a number, got '{v}'"),
360        })
361    }
362
363    #[cfg(test)]
364    mod tests {
365        use super::*;
366
367        #[test]
368        fn parses_status_todo() {
369            let q = parse("status: todo").unwrap();
370            assert_eq!(q.filters.len(), 1);
371            assert!(matches!(q.filters[0], Filter::Status(StatusFilter::Todo)));
372        }
373
374        #[test]
375        fn parses_multiple_filters() {
376            let q = parse("status: todo\ntag: ops\nlimit: 10").unwrap();
377            assert_eq!(q.filters.len(), 2);
378            assert_eq!(q.limit, Some(10));
379        }
380
381        #[test]
382        fn ignores_comments() {
383            let q = parse("# comment\nstatus: done").unwrap();
384            assert_eq!(q.filters.len(), 1);
385        }
386
387        #[test]
388        fn parses_sort() {
389            let q = parse("sort: page, status").unwrap();
390            assert_eq!(q.sort.len(), 2);
391        }
392
393        #[test]
394        fn parses_since() {
395            let q = parse("since: 2w").unwrap();
396            assert!(matches!(q.filters[0], Filter::Since(14)));
397        }
398
399        #[test]
400        fn rejects_unknown_key() {
401            assert!(parse("bogus: value").is_err());
402        }
403    }
404}
405
406/// Execution engine — filter + collect matching blocks.
407pub(crate) mod engine {
408    use super::dsl::{Filter, KindFilter, Query, SortKey, StatusFilter};
409    use chrono::{Duration, NaiveDate};
410    use outl_md::block_index::BlockEntry;
411    use outl_md::index::WorkspaceIndex;
412
413    /// One query hit — the data we need to render an embed.
414    pub struct Hit {
415        /// Block ref handle (`blk-XXXXXX`) for embed rendering.
416        pub handle: String,
417        /// Slug of the page hosting the block.
418        pub page_slug: String,
419        /// `Some(false)` = TODO, `Some(true)` = DONE, `None` = not a task.
420        pub status: Option<bool>,
421        /// Block text with the TODO prefix stripped.
422        pub text: String,
423    }
424
425    /// Run `query` against `index`, returning all matching blocks.
426    pub fn run(index: &WorkspaceIndex, query: &Query) -> Vec<Hit> {
427        let today = chrono::Local::now().date_naive();
428
429        index
430            .iter_blocks()
431            .filter_map(|entry| {
432                let (status, body) = split_todo(&entry.text);
433                let page = index.by_slug(&entry.source_slug);
434
435                for f in &query.filters {
436                    if !matches(f, entry, status, page.map(|p| p.is_journal), &today) {
437                        return None;
438                    }
439                }
440
441                Some(Hit {
442                    handle: entry.ref_handle.clone(),
443                    page_slug: entry.source_slug.clone(),
444                    status,
445                    text: body.to_string(),
446                })
447            })
448            .collect()
449    }
450
451    /// Sort hits by the given criteria, in priority order (last key first
452    /// so the first key dominates after stable sort).
453    pub fn sort_hits(hits: &mut [Hit], keys: &[SortKey]) {
454        for key in keys.iter().rev() {
455            match key {
456                SortKey::Page => hits.sort_by(|a, b| a.page_slug.cmp(&b.page_slug)),
457                SortKey::Status => hits.sort_by(|a, b| {
458                    let a_done = a.status.unwrap_or(false);
459                    let b_done = b.status.unwrap_or(false);
460                    a_done.cmp(&b_done)
461                }),
462                SortKey::Text => hits.sort_by(|a, b| a.text.cmp(&b.text)),
463            }
464        }
465    }
466
467    fn matches(
468        f: &Filter,
469        entry: &BlockEntry,
470        status: Option<bool>,
471        is_journal: Option<bool>,
472        today: &NaiveDate,
473    ) -> bool {
474        match f {
475            Filter::Status(sf) => match sf {
476                StatusFilter::Todo => status == Some(false),
477                StatusFilter::Done => status == Some(true),
478                StatusFilter::Open => status.is_some(),
479            },
480            Filter::Tag(tag) => {
481                let needle = format!("#{}", tag.to_lowercase());
482                entry.text_fold.contains(&needle)
483            }
484            Filter::Kind(kf) => match kf {
485                KindFilter::Journal => is_journal == Some(true),
486                KindFilter::Page => is_journal != Some(true),
487            },
488            Filter::Since(days) => {
489                is_journal == Some(true)
490                    && parse_journal_date(&entry.source_slug)
491                        .map(|d| d >= *today - Duration::days(*days as i64))
492                        .unwrap_or(false)
493            }
494            Filter::Text(needle) => entry.text_fold.contains(&needle.to_lowercase()),
495        }
496    }
497
498    /// Split `"TODO body"` / `"DONE body"` / `"body"` into `(status, body)`.
499    /// Returns `Some(false)` for TODO, `Some(true)` for DONE, `None` otherwise.
500    fn split_todo(raw: &str) -> (Option<bool>, &str) {
501        if let Some(rest) = raw.strip_prefix("TODO ") {
502            (Some(false), rest)
503        } else if let Some(rest) = raw.strip_prefix("DONE ") {
504            (Some(true), rest)
505        } else {
506            (None, raw)
507        }
508    }
509
510    fn parse_journal_date(slug: &str) -> Option<NaiveDate> {
511        NaiveDate::parse_from_str(slug, "%Y-%m-%d").ok()
512    }
513
514    #[cfg(test)]
515    mod tests {
516        use super::*;
517
518        #[test]
519        fn split_todo_open() {
520            assert_eq!(split_todo("TODO buy milk"), (Some(false), "buy milk"));
521        }
522
523        #[test]
524        fn split_todo_done() {
525            assert_eq!(split_todo("DONE buy milk"), (Some(true), "buy milk"));
526        }
527
528        #[test]
529        fn split_todo_none() {
530            assert_eq!(split_todo("just text"), (None, "just text"));
531        }
532    }
533}