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