1use 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#[derive(Debug, Default, Clone)]
30pub struct QueryParams {
31 pub status: Option<String>,
33 pub tag: Option<String>,
35 pub kind: Option<String>,
37 pub since: Option<String>,
39 pub text: Option<String>,
41 pub sort: Vec<String>,
43 pub limit: Option<usize>,
45}
46
47#[derive(Debug, Clone)]
49pub struct QueryHit {
50 pub handle: String,
52 pub page: String,
54 pub status: Option<String>,
56 pub text: String,
58}
59
60pub 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
70pub 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
156pub 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
189pub(crate) mod dsl {
191 use std::fmt;
192
193 #[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 pub line: usize,
234 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 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 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
406pub(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 pub struct Hit {
415 pub handle: String,
417 pub page_slug: String,
419 pub status: Option<bool>,
421 pub text: String,
423 }
424
425 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 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 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}