Skip to main content

magic_bird/query/
parser.rs

1//! Query parser for the cross-client micro-language.
2
3use std::path::PathBuf;
4
5/// A parsed query containing all components.
6#[derive(Debug, Clone, Default, PartialEq)]
7pub struct Query {
8    /// Source selector (host:type:client:session:)
9    pub source: Option<SourceSelector>,
10    /// Working directory filter
11    pub path: Option<PathFilter>,
12    /// Field filters and tags
13    pub filters: Vec<QueryComponent>,
14    /// Range selector (~N or ~N:~M)
15    pub range: Option<RangeSelector>,
16}
17
18/// Source selector for cross-client queries.
19#[derive(Debug, Clone, PartialEq)]
20pub struct SourceSelector {
21    /// Hostname (None = current, Some("*") = all)
22    pub host: Option<String>,
23    /// Source type: shell, claude-code, ci, agent
24    pub source_type: Option<String>,
25    /// Client name: zsh, bash, magic project name
26    pub client: Option<String>,
27    /// Session identifier: PID, UUID, etc.
28    pub session: Option<String>,
29}
30
31impl Default for SourceSelector {
32    #[inline]
33    fn default() -> Self {
34        Self {
35            host: None,
36            source_type: None,
37            client: None,
38            session: None,
39        }
40    }
41}
42
43/// Path-based working directory filter.
44#[derive(Debug, Clone, PartialEq)]
45pub enum PathFilter {
46    /// Current directory (.)
47    Current,
48    /// Relative path (./foo, ../bar)
49    Relative(PathBuf),
50    /// Home-relative path (~/Projects)
51    Home(PathBuf),
52    /// Absolute path (/tmp/foo)
53    Absolute(PathBuf),
54}
55
56/// A query component (filter or tag).
57#[derive(Debug, Clone, PartialEq)]
58pub enum QueryComponent {
59    /// Command regex: %/pattern/
60    CommandRegex(String),
61    /// Field filter: %field<op>value
62    FieldFilter(FieldFilter),
63    /// Tag reference: %tag-name
64    Tag(String),
65}
66
67/// Field filter with comparison operator.
68#[derive(Debug, Clone, PartialEq)]
69pub struct FieldFilter {
70    /// Field name (cmd, exit, cwd, duration, host, type, client, session)
71    pub field: String,
72    /// Comparison operator
73    pub op: CompareOp,
74    /// Value to compare against
75    pub value: String,
76}
77
78/// Comparison operators for field filters.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum CompareOp {
81    /// `=` equals
82    Eq,
83    /// `<>` or `!=` not equals (prefer `<>` to avoid shell history expansion)
84    NotEq,
85    /// `~=` regex match
86    Regex,
87    /// `>` greater than
88    Gt,
89    /// `<` less than
90    Lt,
91    /// `>=` greater or equal
92    Gte,
93    /// `<=` less or equal
94    Lte,
95}
96
97/// Range selector for limiting results.
98///
99/// Uses git-style positional semantics:
100/// - `~N` = single item at position N (1 = most recent)
101/// - `~N:` = range from position N to now (last N items)
102/// - `~N:~M` = range from position N to position M
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct RangeSelector {
105    /// Start position from most recent (1 = most recent, 2 = second most recent)
106    pub start: usize,
107    /// End position: None = single item, Some(0) = open range to now, Some(N) = to position N
108    pub end: Option<usize>,
109}
110
111impl RangeSelector {
112    /// Returns true if this selects a single item (no range).
113    pub fn is_single(&self) -> bool {
114        self.end.is_none()
115    }
116
117    /// Returns true if this is an open range to now (last N items).
118    pub fn is_last_n(&self) -> bool {
119        self.end == Some(0)
120    }
121
122    /// Returns the count of items if this is a "last N" range.
123    pub fn last_n_count(&self) -> Option<usize> {
124        if self.end == Some(0) {
125            Some(self.start)
126        } else {
127            None
128        }
129    }
130}
131
132/// Parse a query string into structured components.
133pub fn parse_query(input: &str) -> Query {
134    let mut query = Query::default();
135    let input = input.trim();
136
137    if input.is_empty() {
138        return query;
139    }
140
141    // Track remaining input as we parse components
142    let mut remaining = input;
143
144    // Try to parse source selector first (contains ':' and ends with ':')
145    if let Some((source, rest)) = try_parse_source(remaining) {
146        query.source = Some(source);
147        remaining = rest;
148    }
149
150    // Try to parse path filter
151    if let Some((path, rest)) = try_parse_path(remaining) {
152        query.path = Some(path);
153        remaining = rest;
154    }
155
156    // Parse remaining components (filters, tags, range)
157    while !remaining.is_empty() {
158        remaining = remaining.trim_start();
159
160        // Check for range at end
161        if let Some((range, rest)) = try_parse_range(remaining) {
162            query.range = Some(range);
163            remaining = rest;
164            continue;
165        }
166
167        // Check for filter/tag (starts with %)
168        if let Some((component, rest)) = try_parse_filter(remaining) {
169            query.filters.push(component);
170            remaining = rest;
171            continue;
172        }
173
174        // Bare word = tag fallback (consume until range or end)
175        if let Some((tag, rest)) = try_parse_bare_tag(remaining) {
176            query.filters.push(QueryComponent::Tag(tag));
177            remaining = rest;
178            continue;
179        }
180
181        // Unknown content, skip a character
182        if !remaining.is_empty() {
183            remaining = &remaining[1..];
184        }
185    }
186
187    query
188}
189
190/// Try to parse a source selector (ends with ':').
191fn try_parse_source(input: &str) -> Option<(SourceSelector, &str)> {
192    // Source selector must contain ':' and the relevant part ends with ':'
193    // But we need to find where the source selector ends
194
195    // Count colons to determine format
196    let mut colon_count = 0;
197    let mut end_pos = 0;
198
199    for (i, c) in input.char_indices() {
200        if c == ':' {
201            colon_count += 1;
202            end_pos = i + 1;
203            // Max 4 colons for host:type:client:session:
204            if colon_count >= 4 {
205                break;
206            }
207        } else if c == '~' || c == '%' || c == '/' || c == '.' {
208            // Start of another component
209            break;
210        }
211    }
212
213    if colon_count == 0 {
214        return None;
215    }
216
217    let source_str = &input[..end_pos];
218    let rest = &input[end_pos..];
219
220    // Parse the source string
221    let parts: Vec<&str> = source_str.trim_end_matches(':').split(':').collect();
222
223    // Interpret based on number of parts
224    let selector = match parts.len() {
225        1 => {
226            // Just type (e.g., "shell:")
227            SourceSelector {
228                source_type: parse_selector_part(parts[0]),
229                ..Default::default()
230            }
231        }
232        2 => {
233            // type:client (e.g., "shell:zsh:")
234            SourceSelector {
235                source_type: parse_selector_part(parts[0]),
236                client: parse_selector_part(parts[1]),
237                ..Default::default()
238            }
239        }
240        3 => {
241            // host:type:client (e.g., "laptop:shell:zsh:")
242            SourceSelector {
243                host: parse_selector_part(parts[0]),
244                source_type: parse_selector_part(parts[1]),
245                client: parse_selector_part(parts[2]),
246                ..Default::default()
247            }
248        }
249        4 => {
250            // host:type:client:session (e.g., "laptop:shell:zsh:123:")
251            SourceSelector {
252                host: parse_selector_part(parts[0]),
253                source_type: parse_selector_part(parts[1]),
254                client: parse_selector_part(parts[2]),
255                session: parse_selector_part(parts[3]),
256            }
257        }
258        _ => return None,
259    };
260
261    Some((selector, rest))
262}
263
264/// Parse a single selector part (empty = None, * = Some("*"), otherwise literal).
265fn parse_selector_part(s: &str) -> Option<String> {
266    if s.is_empty() {
267        None
268    } else {
269        Some(s.to_string())
270    }
271}
272
273/// Try to parse a path filter.
274fn try_parse_path(input: &str) -> Option<(PathFilter, &str)> {
275    // Check for path patterns
276
277    // Current directory: "." alone or "./" or ".~N" (followed by range)
278    if input.starts_with('.') && !input.starts_with("..") {
279        let second_char = input.chars().nth(1);
280        match second_char {
281            None => {
282                // Just "."
283                return Some((PathFilter::Current, ""));
284            }
285            Some('/') => {
286                // "./" or "./path"
287                let end = find_path_end(input);
288                let path_str = &input[..end];
289                let rest = &input[end..];
290                if path_str == "./" {
291                    return Some((PathFilter::Current, rest));
292                } else {
293                    return Some((PathFilter::Relative(PathBuf::from(path_str)), rest));
294                }
295            }
296            Some('~') => {
297                // ".~N" - current dir followed by range
298                return Some((PathFilter::Current, &input[1..]));
299            }
300            Some('%') => {
301                // ".%filter" - current dir followed by filter
302                return Some((PathFilter::Current, &input[1..]));
303            }
304            _ => {}
305        }
306    }
307
308    if input.starts_with("../") || input == ".." {
309        // Parent relative path
310        let end = find_path_end(input);
311        let path_str = &input[..end];
312        let rest = &input[end..];
313        return Some((PathFilter::Relative(PathBuf::from(path_str)), rest));
314    }
315
316    if input.starts_with("~/") {
317        // Home-relative path (NOT range since it has /)
318        let end = find_path_end(input);
319        let path_str = &input[2..end]; // Skip ~/
320        let rest = &input[end..];
321        return Some((PathFilter::Home(PathBuf::from(path_str)), rest));
322    }
323
324    if input.starts_with('/') {
325        // Absolute path
326        let end = find_path_end(input);
327        let path_str = &input[..end];
328        let rest = &input[end..];
329        return Some((PathFilter::Absolute(PathBuf::from(path_str)), rest));
330    }
331
332    None
333}
334
335/// Find where a path ends (before filter/range markers).
336fn find_path_end(input: &str) -> usize {
337    for (i, c) in input.char_indices() {
338        // Range marker after path
339        if c == '~' && i > 0 {
340            // Check if next char is digit (range) vs / (part of path)
341            if let Some(next) = input[i + 1..].chars().next() {
342                if next.is_ascii_digit() {
343                    return i;
344                }
345            }
346        }
347        // Filter marker
348        if c == '%' {
349            return i;
350        }
351    }
352    input.len()
353}
354
355/// Try to parse a range selector.
356///
357/// Git-style positional semantics:
358/// - `~N` = single item at position N
359/// - `~N:` = last N items (range from N to now)
360/// - `~N:~M` or `~N:M` = range from position N to M
361/// - Bare `N` = single item at position N (shorthand for ~N)
362fn try_parse_range(input: &str) -> Option<(RangeSelector, &str)> {
363    // Try ~N format first
364    if input.starts_with('~') {
365        // Check that next char is digit (not / for home path)
366        let chars: Vec<char> = input.chars().collect();
367        if chars.len() < 2 || !chars[1].is_ascii_digit() {
368            return None;
369        }
370
371        // Parse ~N
372        let mut end = 1;
373        while end < input.len() && input[end..].chars().next().is_some_and(|c| c.is_ascii_digit()) {
374            end += 1;
375        }
376
377        let start: usize = input[1..end].parse().ok()?;
378
379        // Check for colon (range indicator)
380        if input[end..].starts_with(':') {
381            let after_colon = &input[end + 1..];
382            // Skip optional ~
383            let range_rest = after_colon.strip_prefix('~').unwrap_or(after_colon);
384
385            // Check if there's a number after the colon
386            let mut range_end = 0;
387            while range_end < range_rest.len()
388                && range_rest[range_end..]
389                    .chars()
390                    .next()
391                    .is_some_and(|c| c.is_ascii_digit())
392            {
393                range_end += 1;
394            }
395
396            if range_end > 0 {
397                // ~N:M or ~N:~M - closed range from N to M
398                let end_val: usize = range_rest[..range_end].parse().ok()?;
399                let rest = &range_rest[range_end..];
400                return Some((
401                    RangeSelector {
402                        start,
403                        end: Some(end_val),
404                    },
405                    rest,
406                ));
407            } else {
408                // ~N: - open range (last N items)
409                // Consume the colon, rest is after it
410                let rest = after_colon;
411                return Some((
412                    RangeSelector {
413                        start,
414                        end: Some(0), // 0 indicates "to now"
415                    },
416                    rest,
417                ));
418            }
419        }
420
421        // ~N alone - single item at position N
422        return Some((
423            RangeSelector { start, end: None },
424            &input[end..],
425        ));
426    }
427
428    // Try bare integer (N = ~N, single item)
429    let first = input.chars().next()?;
430    if first.is_ascii_digit() {
431        let mut end = 0;
432        while end < input.len() && input[end..].chars().next().is_some_and(|c| c.is_ascii_digit()) {
433            end += 1;
434        }
435
436        if end > 0 {
437            let start: usize = input[..end].parse().ok()?;
438            return Some((
439                RangeSelector { start, end: None },
440                &input[end..],
441            ));
442        }
443    }
444
445    None
446}
447
448/// Try to parse a filter component (%, %/, %field<op>value).
449fn try_parse_filter(input: &str) -> Option<(QueryComponent, &str)> {
450    if !input.starts_with('%') {
451        return None;
452    }
453
454    let after_percent = &input[1..];
455
456    // Command regex: %/pattern/
457    if let Some(after_slash) = after_percent.strip_prefix('/') {
458        // Find closing /
459        if let Some(end) = after_slash.find('/') {
460            let pattern = &after_slash[..end];
461            let rest = &after_slash[end + 1..];
462            return Some((QueryComponent::CommandRegex(pattern.to_string()), rest));
463        }
464    }
465
466    // Filter aliases: %failed, %success, %error
467    if let Some(rest) = after_percent.strip_prefix("failed") {
468        let filter = FieldFilter {
469            field: "exit".to_string(),
470            op: CompareOp::NotEq,
471            value: "0".to_string(),
472        };
473        return Some((QueryComponent::FieldFilter(filter), rest));
474    }
475    if let Some(rest) = after_percent.strip_prefix("success") {
476        let filter = FieldFilter {
477            field: "exit".to_string(),
478            op: CompareOp::Eq,
479            value: "0".to_string(),
480        };
481        return Some((QueryComponent::FieldFilter(filter), rest));
482    }
483    if let Some(rest) = after_percent.strip_prefix("ok") {
484        let filter = FieldFilter {
485            field: "exit".to_string(),
486            op: CompareOp::Eq,
487            value: "0".to_string(),
488        };
489        return Some((QueryComponent::FieldFilter(filter), rest));
490    }
491
492    // Try field filter: %field<op>value
493    if let Some((filter, rest)) = try_parse_field_filter(after_percent) {
494        return Some((QueryComponent::FieldFilter(filter), rest));
495    }
496
497    // Tag: %bare-word (no operator)
498    let end = find_filter_end(after_percent);
499    if end > 0 {
500        let tag = &after_percent[..end];
501        let rest = &after_percent[end..];
502        return Some((QueryComponent::Tag(tag.to_string()), rest));
503    }
504
505    None
506}
507
508/// Try to parse a field filter (field<op>value).
509fn try_parse_field_filter(input: &str) -> Option<(FieldFilter, &str)> {
510    // Known field names
511    let fields = ["cmd", "exit", "cwd", "duration", "host", "type", "client", "session"];
512
513    for field in &fields {
514        if let Some(after_field) = input.strip_prefix(field) {
515
516            // Try each operator (order matters: check 2-char ops before 1-char)
517            let (op, op_len) = if after_field.starts_with("~=") {
518                (CompareOp::Regex, 2)
519            } else if after_field.starts_with("<>") || after_field.starts_with("!=") {
520                // Both <> and != mean not-equal (<> preferred since ! needs shell escaping)
521                (CompareOp::NotEq, 2)
522            } else if after_field.starts_with(">=") {
523                (CompareOp::Gte, 2)
524            } else if after_field.starts_with("<=") {
525                (CompareOp::Lte, 2)
526            } else if after_field.starts_with('=') {
527                (CompareOp::Eq, 1)
528            } else if after_field.starts_with('>') {
529                (CompareOp::Gt, 1)
530            } else if after_field.starts_with('<') {
531                (CompareOp::Lt, 1)
532            } else {
533                continue;
534            };
535
536            let after_op = &after_field[op_len..];
537            let value_end = find_filter_end(after_op);
538            let value = &after_op[..value_end];
539            let rest = &after_op[value_end..];
540
541            return Some((
542                FieldFilter {
543                    field: field.to_string(),
544                    op,
545                    value: value.to_string(),
546                },
547                rest,
548            ));
549        }
550    }
551
552    None
553}
554
555/// Find where a filter value/tag ends.
556fn find_filter_end(input: &str) -> usize {
557    for (i, c) in input.char_indices() {
558        if c == '~' || c == '%' || c.is_whitespace() {
559            return i;
560        }
561    }
562    input.len()
563}
564
565/// Try to parse a bare tag (word without %).
566fn try_parse_bare_tag(input: &str) -> Option<(String, &str)> {
567    if input.is_empty() {
568        return None;
569    }
570
571    // Must start with alphanumeric or -
572    let first = input.chars().next()?;
573    if !first.is_alphanumeric() && first != '-' && first != '_' {
574        return None;
575    }
576
577    let end = find_filter_end(input);
578    if end > 0 {
579        let tag = &input[..end];
580        let rest = &input[end..];
581        Some((tag.to_string(), rest))
582    } else {
583        None
584    }
585}
586
587impl Query {
588    /// Check if this query matches everything (no filters applied).
589    pub fn is_match_all(&self) -> bool {
590        self.source.is_none()
591            && self.path.is_none()
592            && self.filters.is_empty()
593            && self.range.is_none()
594    }
595
596    /// Check if source selector is for all sources.
597    pub fn is_all_sources(&self) -> bool {
598        match &self.source {
599            None => false,
600            Some(s) => {
601                s.host.as_deref() == Some("*")
602                    && s.source_type.as_deref() == Some("*")
603                    && s.client.as_deref() == Some("*")
604                    && s.session.as_deref() == Some("*")
605            }
606        }
607    }
608}
609
610impl std::fmt::Display for CompareOp {
611    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
612        match self {
613            CompareOp::Eq => write!(f, "="),
614            CompareOp::NotEq => write!(f, "<>"), // Canonical form (shell-friendly)
615            CompareOp::Regex => write!(f, "~="),
616            CompareOp::Gt => write!(f, ">"),
617            CompareOp::Lt => write!(f, "<"),
618            CompareOp::Gte => write!(f, ">="),
619            CompareOp::Lte => write!(f, "<="),
620        }
621    }
622}