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)]
99pub struct RangeSelector {
100 pub start: usize,
102 pub end: Option<usize>,
104}
105
106pub 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 let mut remaining = input;
117
118 if let Some((source, rest)) = try_parse_source(remaining) {
120 query.source = Some(source);
121 remaining = rest;
122 }
123
124 if let Some((path, rest)) = try_parse_path(remaining) {
126 query.path = Some(path);
127 remaining = rest;
128 }
129
130 while !remaining.is_empty() {
132 remaining = remaining.trim_start();
133
134 if let Some((range, rest)) = try_parse_range(remaining) {
136 query.range = Some(range);
137 remaining = rest;
138 continue;
139 }
140
141 if let Some((component, rest)) = try_parse_filter(remaining) {
143 query.filters.push(component);
144 remaining = rest;
145 continue;
146 }
147
148 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 if !remaining.is_empty() {
157 remaining = &remaining[1..];
158 }
159 }
160
161 query
162}
163
164fn try_parse_source(input: &str) -> Option<(SourceSelector, &str)> {
166 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 if colon_count >= 4 {
179 break;
180 }
181 } else if c == '~' || c == '%' || c == '/' || c == '.' {
182 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 let parts: Vec<&str> = source_str.trim_end_matches(':').split(':').collect();
196
197 let selector = match parts.len() {
199 1 => {
200 SourceSelector {
202 source_type: parse_selector_part(parts[0]),
203 ..Default::default()
204 }
205 }
206 2 => {
207 SourceSelector {
209 source_type: parse_selector_part(parts[0]),
210 client: parse_selector_part(parts[1]),
211 ..Default::default()
212 }
213 }
214 3 => {
215 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 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
238fn parse_selector_part(s: &str) -> Option<String> {
240 if s.is_empty() {
241 None
242 } else {
243 Some(s.to_string())
244 }
245}
246
247fn try_parse_path(input: &str) -> Option<(PathFilter, &str)> {
249 if input.starts_with('.') && !input.starts_with("..") {
253 let second_char = input.chars().nth(1);
254 match second_char {
255 None => {
256 return Some((PathFilter::Current, ""));
258 }
259 Some('/') => {
260 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 return Some((PathFilter::Current, &input[1..]));
273 }
274 Some('%') => {
275 return Some((PathFilter::Current, &input[1..]));
277 }
278 _ => {}
279 }
280 }
281
282 if input.starts_with("../") || input == ".." {
283 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 let end = find_path_end(input);
293 let path_str = &input[2..end]; let rest = &input[end..];
295 return Some((PathFilter::Home(PathBuf::from(path_str)), rest));
296 }
297
298 if input.starts_with('/') {
299 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
309fn find_path_end(input: &str) -> usize {
311 for (i, c) in input.char_indices() {
312 if c == '~' && i > 0 {
314 if let Some(next) = input[i + 1..].chars().next() {
316 if next.is_ascii_digit() {
317 return i;
318 }
319 }
320 }
321 if c == '%' {
323 return i;
324 }
325 }
326 input.len()
327}
328
329fn try_parse_range(input: &str) -> Option<(RangeSelector, &str)> {
331 if input.starts_with('~') {
333 let chars: Vec<char> = input.chars().collect();
335 if chars.len() < 2 || !chars[1].is_ascii_digit() {
336 return None;
337 }
338
339 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 if input[end..].starts_with(':') {
349 let after_colon = &input[end + 1..];
350 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 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
402fn 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 if let Some(after_slash) = after_percent.strip_prefix('/') {
412 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 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 if let Some((filter, rest)) = try_parse_field_filter(after_percent) {
448 return Some((QueryComponent::FieldFilter(filter), rest));
449 }
450
451 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
462fn try_parse_field_filter(input: &str) -> Option<(FieldFilter, &str)> {
464 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 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 (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
509fn 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
519fn try_parse_bare_tag(input: &str) -> Option<(String, &str)> {
521 if input.is_empty() {
522 return None;
523 }
524
525 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 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 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, "<>"), 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}