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 fn default() -> Self {
33 Self {
34 host: None,
35 source_type: None,
36 client: None,
37 session: None,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq)]
44pub enum PathFilter {
45 Current,
47 Relative(PathBuf),
49 Home(PathBuf),
51 Absolute(PathBuf),
53}
54
55#[derive(Debug, Clone, PartialEq)]
57pub enum QueryComponent {
58 CommandRegex(String),
60 FieldFilter(FieldFilter),
62 Tag(String),
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub struct FieldFilter {
69 pub field: String,
71 pub op: CompareOp,
73 pub value: String,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum CompareOp {
80 Eq,
82 NotEq,
84 Regex,
86 Gt,
88 Lt,
90 Gte,
92 Lte,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct RangeSelector {
99 pub start: usize,
101 pub end: Option<usize>,
103}
104
105pub 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 let mut remaining = input;
116
117 if let Some((source, rest)) = try_parse_source(remaining) {
119 query.source = Some(source);
120 remaining = rest;
121 }
122
123 if let Some((path, rest)) = try_parse_path(remaining) {
125 query.path = Some(path);
126 remaining = rest;
127 }
128
129 while !remaining.is_empty() {
131 remaining = remaining.trim_start();
132
133 if let Some((range, rest)) = try_parse_range(remaining) {
135 query.range = Some(range);
136 remaining = rest;
137 continue;
138 }
139
140 if let Some((component, rest)) = try_parse_filter(remaining) {
142 query.filters.push(component);
143 remaining = rest;
144 continue;
145 }
146
147 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 if !remaining.is_empty() {
156 remaining = &remaining[1..];
157 }
158 }
159
160 query
161}
162
163fn try_parse_source(input: &str) -> Option<(SourceSelector, &str)> {
165 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 if colon_count >= 4 {
178 break;
179 }
180 } else if c == '~' || c == '%' || c == '/' || c == '.' {
181 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 let parts: Vec<&str> = source_str.trim_end_matches(':').split(':').collect();
195
196 let selector = match parts.len() {
198 1 => {
199 SourceSelector {
201 source_type: parse_selector_part(parts[0]),
202 ..Default::default()
203 }
204 }
205 2 => {
206 SourceSelector {
208 source_type: parse_selector_part(parts[0]),
209 client: parse_selector_part(parts[1]),
210 ..Default::default()
211 }
212 }
213 3 => {
214 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 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
237fn parse_selector_part(s: &str) -> Option<String> {
239 if s.is_empty() {
240 None
241 } else {
242 Some(s.to_string())
243 }
244}
245
246fn try_parse_path(input: &str) -> Option<(PathFilter, &str)> {
248 if input.starts_with('.') && !input.starts_with("..") {
252 let second_char = input.chars().nth(1);
253 match second_char {
254 None => {
255 return Some((PathFilter::Current, ""));
257 }
258 Some('/') => {
259 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 return Some((PathFilter::Current, &input[1..]));
272 }
273 Some('%') => {
274 return Some((PathFilter::Current, &input[1..]));
276 }
277 _ => {}
278 }
279 }
280
281 if input.starts_with("../") || input == ".." {
282 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 let end = find_path_end(input);
292 let path_str = &input[2..end]; let rest = &input[end..];
294 return Some((PathFilter::Home(PathBuf::from(path_str)), rest));
295 }
296
297 if input.starts_with('/') {
298 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
308fn find_path_end(input: &str) -> usize {
310 for (i, c) in input.char_indices() {
311 if c == '~' && i > 0 {
313 if let Some(next) = input[i + 1..].chars().next() {
315 if next.is_ascii_digit() {
316 return i;
317 }
318 }
319 }
320 if c == '%' {
322 return i;
323 }
324 }
325 input.len()
326}
327
328fn try_parse_range(input: &str) -> Option<(RangeSelector, &str)> {
330 if input.starts_with('~') {
332 let chars: Vec<char> = input.chars().collect();
334 if chars.len() < 2 || !chars[1].is_ascii_digit() {
335 return None;
336 }
337
338 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 if input[end..].starts_with(':') {
348 let after_colon = &input[end + 1..];
349 let (range_rest, skip) = if after_colon.starts_with('~') {
351 (&after_colon[1..], 0)
352 } else {
353 (after_colon, 0)
354 };
355 let _ = skip; 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 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
406fn 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 if after_percent.starts_with('/') {
416 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 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 if let Some((filter, rest)) = try_parse_field_filter(after_percent) {
452 return Some((QueryComponent::FieldFilter(filter), rest));
453 }
454
455 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
466fn try_parse_field_filter(input: &str) -> Option<(FieldFilter, &str)> {
468 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 let (op, op_len) = if after_field.starts_with("~=") {
477 (CompareOp::Regex, 2)
478 } else if after_field.starts_with("<>") {
479 (CompareOp::NotEq, 2) } else if after_field.starts_with("!=") {
481 (CompareOp::NotEq, 2) } 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
515fn 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
525fn try_parse_bare_tag(input: &str) -> Option<(String, &str)> {
527 if input.is_empty() {
528 return None;
529 }
530
531 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 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 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, "<>"), 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}