1use std::path::PathBuf;
4
5#[derive(Debug, Clone, Default, PartialEq)]
7pub struct Query {
8 pub source: Option<SourceSelector>,
10 pub path: Option<PathFilter>,
12 pub filters: Vec<QueryComponent>,
14 pub range: Option<RangeSelector>,
16}
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct SourceSelector {
21 pub host: Option<String>,
23 pub source_type: Option<String>,
25 pub client: Option<String>,
27 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#[derive(Debug, Clone, PartialEq)]
45pub enum PathFilter {
46 Current,
48 Relative(PathBuf),
50 Home(PathBuf),
52 Absolute(PathBuf),
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub enum QueryComponent {
59 CommandRegex(String),
61 FieldFilter(FieldFilter),
63 Tag(String),
65}
66
67#[derive(Debug, Clone, PartialEq)]
69pub struct FieldFilter {
70 pub field: String,
72 pub op: CompareOp,
74 pub value: String,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum CompareOp {
81 Eq,
83 NotEq,
85 Regex,
87 Gt,
89 Lt,
91 Gte,
93 Lte,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct RangeSelector {
105 pub start: usize,
107 pub end: Option<usize>,
109}
110
111impl RangeSelector {
112 pub fn is_single(&self) -> bool {
114 self.end.is_none()
115 }
116
117 pub fn is_last_n(&self) -> bool {
119 self.end == Some(0)
120 }
121
122 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
132pub 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 let mut remaining = input;
143
144 if let Some((source, rest)) = try_parse_source(remaining) {
146 query.source = Some(source);
147 remaining = rest;
148 }
149
150 if let Some((path, rest)) = try_parse_path(remaining) {
152 query.path = Some(path);
153 remaining = rest;
154 }
155
156 while !remaining.is_empty() {
158 remaining = remaining.trim_start();
159
160 if let Some((range, rest)) = try_parse_range(remaining) {
162 query.range = Some(range);
163 remaining = rest;
164 continue;
165 }
166
167 if let Some((component, rest)) = try_parse_filter(remaining) {
169 query.filters.push(component);
170 remaining = rest;
171 continue;
172 }
173
174 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 if !remaining.is_empty() {
183 remaining = &remaining[1..];
184 }
185 }
186
187 query
188}
189
190fn try_parse_source(input: &str) -> Option<(SourceSelector, &str)> {
192 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 if colon_count >= 4 {
205 break;
206 }
207 } else if c == '~' || c == '%' || c == '/' || c == '.' {
208 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 let parts: Vec<&str> = source_str.trim_end_matches(':').split(':').collect();
222
223 let selector = match parts.len() {
225 1 => {
226 SourceSelector {
228 source_type: parse_selector_part(parts[0]),
229 ..Default::default()
230 }
231 }
232 2 => {
233 SourceSelector {
235 source_type: parse_selector_part(parts[0]),
236 client: parse_selector_part(parts[1]),
237 ..Default::default()
238 }
239 }
240 3 => {
241 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 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
264fn parse_selector_part(s: &str) -> Option<String> {
266 if s.is_empty() {
267 None
268 } else {
269 Some(s.to_string())
270 }
271}
272
273fn try_parse_path(input: &str) -> Option<(PathFilter, &str)> {
275 if input.starts_with('.') && !input.starts_with("..") {
279 let second_char = input.chars().nth(1);
280 match second_char {
281 None => {
282 return Some((PathFilter::Current, ""));
284 }
285 Some('/') => {
286 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 return Some((PathFilter::Current, &input[1..]));
299 }
300 Some('%') => {
301 return Some((PathFilter::Current, &input[1..]));
303 }
304 _ => {}
305 }
306 }
307
308 if input.starts_with("../") || input == ".." {
309 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 let end = find_path_end(input);
319 let path_str = &input[2..end]; let rest = &input[end..];
321 return Some((PathFilter::Home(PathBuf::from(path_str)), rest));
322 }
323
324 if input.starts_with('/') {
325 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
335fn find_path_end(input: &str) -> usize {
337 for (i, c) in input.char_indices() {
338 if c == '~' && i > 0 {
340 if let Some(next) = input[i + 1..].chars().next() {
342 if next.is_ascii_digit() {
343 return i;
344 }
345 }
346 }
347 if c == '%' {
349 return i;
350 }
351 }
352 input.len()
353}
354
355fn try_parse_range(input: &str) -> Option<(RangeSelector, &str)> {
363 if input.starts_with('~') {
365 let chars: Vec<char> = input.chars().collect();
367 if chars.len() < 2 || !chars[1].is_ascii_digit() {
368 return None;
369 }
370
371 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 if input[end..].starts_with(':') {
381 let after_colon = &input[end + 1..];
382 let range_rest = after_colon.strip_prefix('~').unwrap_or(after_colon);
384
385 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 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 let rest = after_colon;
411 return Some((
412 RangeSelector {
413 start,
414 end: Some(0), },
416 rest,
417 ));
418 }
419 }
420
421 return Some((
423 RangeSelector { start, end: None },
424 &input[end..],
425 ));
426 }
427
428 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
448fn 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 if let Some(after_slash) = after_percent.strip_prefix('/') {
458 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 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 if let Some((filter, rest)) = try_parse_field_filter(after_percent) {
494 return Some((QueryComponent::FieldFilter(filter), rest));
495 }
496
497 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
508fn try_parse_field_filter(input: &str) -> Option<(FieldFilter, &str)> {
510 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 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 (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
555fn 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
565fn try_parse_bare_tag(input: &str) -> Option<(String, &str)> {
567 if input.is_empty() {
568 return None;
569 }
570
571 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 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 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, "<>"), 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}