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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub struct RangeSelector {
100    /// Start offset from most recent (e.g., 5 = 5th most recent)
101    pub start: usize,
102    /// End offset (None = just start count, Some = range)
103    pub end: Option<usize>,
104}
105
106/// Parse a query string into structured components.
107pub fn parse_query(input: &str) -> Query {
108    let mut query = Query::default();
109    let input = input.trim();
110
111    if input.is_empty() {
112        return query;
113    }
114
115    // Track remaining input as we parse components
116    let mut remaining = input;
117
118    // Try to parse source selector first (contains ':' and ends with ':')
119    if let Some((source, rest)) = try_parse_source(remaining) {
120        query.source = Some(source);
121        remaining = rest;
122    }
123
124    // Try to parse path filter
125    if let Some((path, rest)) = try_parse_path(remaining) {
126        query.path = Some(path);
127        remaining = rest;
128    }
129
130    // Parse remaining components (filters, tags, range)
131    while !remaining.is_empty() {
132        remaining = remaining.trim_start();
133
134        // Check for range at end
135        if let Some((range, rest)) = try_parse_range(remaining) {
136            query.range = Some(range);
137            remaining = rest;
138            continue;
139        }
140
141        // Check for filter/tag (starts with %)
142        if let Some((component, rest)) = try_parse_filter(remaining) {
143            query.filters.push(component);
144            remaining = rest;
145            continue;
146        }
147
148        // Bare word = tag fallback (consume until range or end)
149        if let Some((tag, rest)) = try_parse_bare_tag(remaining) {
150            query.filters.push(QueryComponent::Tag(tag));
151            remaining = rest;
152            continue;
153        }
154
155        // Unknown content, skip a character
156        if !remaining.is_empty() {
157            remaining = &remaining[1..];
158        }
159    }
160
161    query
162}
163
164/// Try to parse a source selector (ends with ':').
165fn try_parse_source(input: &str) -> Option<(SourceSelector, &str)> {
166    // Source selector must contain ':' and the relevant part ends with ':'
167    // But we need to find where the source selector ends
168
169    // Count colons to determine format
170    let mut colon_count = 0;
171    let mut end_pos = 0;
172
173    for (i, c) in input.char_indices() {
174        if c == ':' {
175            colon_count += 1;
176            end_pos = i + 1;
177            // Max 4 colons for host:type:client:session:
178            if colon_count >= 4 {
179                break;
180            }
181        } else if c == '~' || c == '%' || c == '/' || c == '.' {
182            // Start of another component
183            break;
184        }
185    }
186
187    if colon_count == 0 {
188        return None;
189    }
190
191    let source_str = &input[..end_pos];
192    let rest = &input[end_pos..];
193
194    // Parse the source string
195    let parts: Vec<&str> = source_str.trim_end_matches(':').split(':').collect();
196
197    // Interpret based on number of parts
198    let selector = match parts.len() {
199        1 => {
200            // Just type (e.g., "shell:")
201            SourceSelector {
202                source_type: parse_selector_part(parts[0]),
203                ..Default::default()
204            }
205        }
206        2 => {
207            // type:client (e.g., "shell:zsh:")
208            SourceSelector {
209                source_type: parse_selector_part(parts[0]),
210                client: parse_selector_part(parts[1]),
211                ..Default::default()
212            }
213        }
214        3 => {
215            // host:type:client (e.g., "laptop:shell:zsh:")
216            SourceSelector {
217                host: parse_selector_part(parts[0]),
218                source_type: parse_selector_part(parts[1]),
219                client: parse_selector_part(parts[2]),
220                ..Default::default()
221            }
222        }
223        4 => {
224            // host:type:client:session (e.g., "laptop:shell:zsh:123:")
225            SourceSelector {
226                host: parse_selector_part(parts[0]),
227                source_type: parse_selector_part(parts[1]),
228                client: parse_selector_part(parts[2]),
229                session: parse_selector_part(parts[3]),
230            }
231        }
232        _ => return None,
233    };
234
235    Some((selector, rest))
236}
237
238/// Parse a single selector part (empty = None, * = Some("*"), otherwise literal).
239fn parse_selector_part(s: &str) -> Option<String> {
240    if s.is_empty() {
241        None
242    } else {
243        Some(s.to_string())
244    }
245}
246
247/// Try to parse a path filter.
248fn try_parse_path(input: &str) -> Option<(PathFilter, &str)> {
249    // Check for path patterns
250
251    // Current directory: "." alone or "./" or ".~N" (followed by range)
252    if input.starts_with('.') && !input.starts_with("..") {
253        let second_char = input.chars().nth(1);
254        match second_char {
255            None => {
256                // Just "."
257                return Some((PathFilter::Current, ""));
258            }
259            Some('/') => {
260                // "./" or "./path"
261                let end = find_path_end(input);
262                let path_str = &input[..end];
263                let rest = &input[end..];
264                if path_str == "./" {
265                    return Some((PathFilter::Current, rest));
266                } else {
267                    return Some((PathFilter::Relative(PathBuf::from(path_str)), rest));
268                }
269            }
270            Some('~') => {
271                // ".~N" - current dir followed by range
272                return Some((PathFilter::Current, &input[1..]));
273            }
274            Some('%') => {
275                // ".%filter" - current dir followed by filter
276                return Some((PathFilter::Current, &input[1..]));
277            }
278            _ => {}
279        }
280    }
281
282    if input.starts_with("../") || input == ".." {
283        // Parent relative path
284        let end = find_path_end(input);
285        let path_str = &input[..end];
286        let rest = &input[end..];
287        return Some((PathFilter::Relative(PathBuf::from(path_str)), rest));
288    }
289
290    if input.starts_with("~/") {
291        // Home-relative path (NOT range since it has /)
292        let end = find_path_end(input);
293        let path_str = &input[2..end]; // Skip ~/
294        let rest = &input[end..];
295        return Some((PathFilter::Home(PathBuf::from(path_str)), rest));
296    }
297
298    if input.starts_with('/') {
299        // Absolute path
300        let end = find_path_end(input);
301        let path_str = &input[..end];
302        let rest = &input[end..];
303        return Some((PathFilter::Absolute(PathBuf::from(path_str)), rest));
304    }
305
306    None
307}
308
309/// Find where a path ends (before filter/range markers).
310fn find_path_end(input: &str) -> usize {
311    for (i, c) in input.char_indices() {
312        // Range marker after path
313        if c == '~' && i > 0 {
314            // Check if next char is digit (range) vs / (part of path)
315            if let Some(next) = input[i + 1..].chars().next() {
316                if next.is_ascii_digit() {
317                    return i;
318                }
319            }
320        }
321        // Filter marker
322        if c == '%' {
323            return i;
324        }
325    }
326    input.len()
327}
328
329/// Try to parse a range selector (~N, ~N:~M, or bare N).
330fn try_parse_range(input: &str) -> Option<(RangeSelector, &str)> {
331    // Try ~N format first
332    if input.starts_with('~') {
333        // Check that next char is digit (not / for home path)
334        let chars: Vec<char> = input.chars().collect();
335        if chars.len() < 2 || !chars[1].is_ascii_digit() {
336            return None;
337        }
338
339        // Parse ~N
340        let mut end = 1;
341        while end < input.len() && input[end..].chars().next().is_some_and(|c| c.is_ascii_digit()) {
342            end += 1;
343        }
344
345        let start: usize = input[1..end].parse().ok()?;
346
347        // Check for :M or :~M range
348        if input[end..].starts_with(':') {
349            let after_colon = &input[end + 1..];
350            // Skip optional ~
351            let range_rest = after_colon.strip_prefix('~').unwrap_or(after_colon);
352
353            let mut range_end = 0;
354            while range_end < range_rest.len()
355                && range_rest[range_end..]
356                    .chars()
357                    .next()
358                    .is_some_and(|c| c.is_ascii_digit())
359            {
360                range_end += 1;
361            }
362
363            if range_end > 0 {
364                let end_val: usize = range_rest[..range_end].parse().ok()?;
365                let rest = &range_rest[range_end..];
366                return Some((
367                    RangeSelector {
368                        start,
369                        end: Some(end_val),
370                    },
371                    rest,
372                ));
373            }
374        }
375
376        return Some((
377            RangeSelector { start, end: None },
378            &input[end..],
379        ));
380    }
381
382    // Try bare integer (N = ~N)
383    let first = input.chars().next()?;
384    if first.is_ascii_digit() {
385        let mut end = 0;
386        while end < input.len() && input[end..].chars().next().is_some_and(|c| c.is_ascii_digit()) {
387            end += 1;
388        }
389
390        if end > 0 {
391            let start: usize = input[..end].parse().ok()?;
392            return Some((
393                RangeSelector { start, end: None },
394                &input[end..],
395            ));
396        }
397    }
398
399    None
400}
401
402/// Try to parse a filter component (%, %/, %field<op>value).
403fn try_parse_filter(input: &str) -> Option<(QueryComponent, &str)> {
404    if !input.starts_with('%') {
405        return None;
406    }
407
408    let after_percent = &input[1..];
409
410    // Command regex: %/pattern/
411    if let Some(after_slash) = after_percent.strip_prefix('/') {
412        // Find closing /
413        if let Some(end) = after_slash.find('/') {
414            let pattern = &after_slash[..end];
415            let rest = &after_slash[end + 1..];
416            return Some((QueryComponent::CommandRegex(pattern.to_string()), rest));
417        }
418    }
419
420    // Filter aliases: %failed, %success, %error
421    if let Some(rest) = after_percent.strip_prefix("failed") {
422        let filter = FieldFilter {
423            field: "exit".to_string(),
424            op: CompareOp::NotEq,
425            value: "0".to_string(),
426        };
427        return Some((QueryComponent::FieldFilter(filter), rest));
428    }
429    if let Some(rest) = after_percent.strip_prefix("success") {
430        let filter = FieldFilter {
431            field: "exit".to_string(),
432            op: CompareOp::Eq,
433            value: "0".to_string(),
434        };
435        return Some((QueryComponent::FieldFilter(filter), rest));
436    }
437    if let Some(rest) = after_percent.strip_prefix("ok") {
438        let filter = FieldFilter {
439            field: "exit".to_string(),
440            op: CompareOp::Eq,
441            value: "0".to_string(),
442        };
443        return Some((QueryComponent::FieldFilter(filter), rest));
444    }
445
446    // Try field filter: %field<op>value
447    if let Some((filter, rest)) = try_parse_field_filter(after_percent) {
448        return Some((QueryComponent::FieldFilter(filter), rest));
449    }
450
451    // Tag: %bare-word (no operator)
452    let end = find_filter_end(after_percent);
453    if end > 0 {
454        let tag = &after_percent[..end];
455        let rest = &after_percent[end..];
456        return Some((QueryComponent::Tag(tag.to_string()), rest));
457    }
458
459    None
460}
461
462/// Try to parse a field filter (field<op>value).
463fn try_parse_field_filter(input: &str) -> Option<(FieldFilter, &str)> {
464    // Known field names
465    let fields = ["cmd", "exit", "cwd", "duration", "host", "type", "client", "session"];
466
467    for field in &fields {
468        if let Some(after_field) = input.strip_prefix(field) {
469
470            // Try each operator (order matters: check 2-char ops before 1-char)
471            let (op, op_len) = if after_field.starts_with("~=") {
472                (CompareOp::Regex, 2)
473            } else if after_field.starts_with("<>") || after_field.starts_with("!=") {
474                // Both <> and != mean not-equal (<> preferred since ! needs shell escaping)
475                (CompareOp::NotEq, 2)
476            } else if after_field.starts_with(">=") {
477                (CompareOp::Gte, 2)
478            } else if after_field.starts_with("<=") {
479                (CompareOp::Lte, 2)
480            } else if after_field.starts_with('=') {
481                (CompareOp::Eq, 1)
482            } else if after_field.starts_with('>') {
483                (CompareOp::Gt, 1)
484            } else if after_field.starts_with('<') {
485                (CompareOp::Lt, 1)
486            } else {
487                continue;
488            };
489
490            let after_op = &after_field[op_len..];
491            let value_end = find_filter_end(after_op);
492            let value = &after_op[..value_end];
493            let rest = &after_op[value_end..];
494
495            return Some((
496                FieldFilter {
497                    field: field.to_string(),
498                    op,
499                    value: value.to_string(),
500                },
501                rest,
502            ));
503        }
504    }
505
506    None
507}
508
509/// Find where a filter value/tag ends.
510fn find_filter_end(input: &str) -> usize {
511    for (i, c) in input.char_indices() {
512        if c == '~' || c == '%' || c.is_whitespace() {
513            return i;
514        }
515    }
516    input.len()
517}
518
519/// Try to parse a bare tag (word without %).
520fn try_parse_bare_tag(input: &str) -> Option<(String, &str)> {
521    if input.is_empty() {
522        return None;
523    }
524
525    // Must start with alphanumeric or -
526    let first = input.chars().next()?;
527    if !first.is_alphanumeric() && first != '-' && first != '_' {
528        return None;
529    }
530
531    let end = find_filter_end(input);
532    if end > 0 {
533        let tag = &input[..end];
534        let rest = &input[end..];
535        Some((tag.to_string(), rest))
536    } else {
537        None
538    }
539}
540
541impl Query {
542    /// Check if this query matches everything (no filters applied).
543    pub fn is_match_all(&self) -> bool {
544        self.source.is_none()
545            && self.path.is_none()
546            && self.filters.is_empty()
547            && self.range.is_none()
548    }
549
550    /// Check if source selector is for all sources.
551    pub fn is_all_sources(&self) -> bool {
552        match &self.source {
553            None => false,
554            Some(s) => {
555                s.host.as_deref() == Some("*")
556                    && s.source_type.as_deref() == Some("*")
557                    && s.client.as_deref() == Some("*")
558                    && s.session.as_deref() == Some("*")
559            }
560        }
561    }
562}
563
564impl std::fmt::Display for CompareOp {
565    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
566        match self {
567            CompareOp::Eq => write!(f, "="),
568            CompareOp::NotEq => write!(f, "<>"), // Canonical form (shell-friendly)
569            CompareOp::Regex => write!(f, "~="),
570            CompareOp::Gt => write!(f, ">"),
571            CompareOp::Lt => write!(f, "<"),
572            CompareOp::Gte => write!(f, ">="),
573            CompareOp::Lte => write!(f, "<="),
574        }
575    }
576}