use anyhow::{Result, bail};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Cmp {
Gt,
Ge,
Lt,
Le,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DateBound {
pub cmp: Cmp,
pub start_ms: i64,
pub next_ms: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SizeBound {
pub cmp: Cmp,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PathTerm {
pub operand: String,
pub phrase: bool,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Group {
pub content: Vec<String>,
pub excluded: Vec<String>,
pub paths: Vec<PathTerm>,
pub mtimes: Vec<DateBound>,
pub sizes: Vec<SizeBound>,
}
impl Group {
fn is_empty(&self) -> bool {
self.content.is_empty()
&& self.excluded.is_empty()
&& self.paths.is_empty()
&& self.mtimes.is_empty()
&& self.sizes.is_empty()
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Parsed {
pub has_operators: bool,
pub groups: Vec<Group>,
}
fn tokenize(query: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_quote = false;
let mut has = false;
for ch in query.chars() {
if ch == '"' {
in_quote = !in_quote;
cur.push(ch);
has = true;
} else if ch.is_whitespace() && !in_quote {
if has {
out.push(std::mem::take(&mut cur));
has = false;
}
} else {
cur.push(ch);
has = true;
}
}
if has {
out.push(cur);
}
out
}
fn split_field(token: &str) -> Option<(String, &str)> {
let colon = token.find(':')?;
let field = &token[..colon];
if field.is_empty() || !field.bytes().all(|b| b.is_ascii_alphabetic()) {
return None;
}
Some((field.to_ascii_lowercase(), &token[colon + 1..]))
}
fn is_operator_token(token: &str) -> bool {
if token == "OR" || token == "NOT" {
return true;
}
if token.strip_prefix('-').is_some_and(|rest| !rest.is_empty()) {
return true;
}
matches!(
split_field(token),
Some((ref f, _)) if f == "path" || f == "mtime" || f == "size"
)
}
fn unquote(s: &str) -> (String, bool) {
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
(s[1..s.len() - 1].to_string(), true)
} else {
(s.to_string(), false)
}
}
pub(crate) fn parse(query: &str) -> Result<Parsed> {
let tokens = tokenize(query);
if !tokens.iter().any(|t| is_operator_token(t)) {
return Ok(Parsed {
has_operators: false,
groups: Vec::new(),
});
}
let mut groups_tokens: Vec<Vec<&str>> = vec![Vec::new()];
for tok in &tokens {
if tok == "OR" {
groups_tokens.push(Vec::new());
} else {
groups_tokens
.last_mut()
.expect("至少有一组")
.push(tok.as_str());
}
}
let mut groups = Vec::new();
for gt in groups_tokens {
let group = classify_group(>)?;
if !group.is_empty() {
groups.push(group);
}
}
if groups.is_empty() {
bail!("查询里只有 OR / NOT 之类的连接词,没有实际的检索条件");
}
Ok(Parsed {
has_operators: true,
groups,
})
}
fn classify_group(tokens: &[&str]) -> Result<Group> {
let mut g = Group::default();
let mut i = 0;
while i < tokens.len() {
let tok = tokens[i];
i += 1;
if tok == "NOT" {
match tokens.get(i) {
Some(next) => {
g.excluded.push((*next).to_string());
i += 1;
}
None => bail!("NOT 后面缺少要排除的词,例如 `限流 NOT 废弃`"),
}
continue;
}
if let Some(rest) = tok.strip_prefix('-') {
if !rest.is_empty() {
g.excluded.push(rest.to_string());
continue;
}
}
if let Some((field, rest)) = split_field(tok) {
match field.as_str() {
"path" => {
let (operand, phrase) = unquote(rest);
if operand.trim().is_empty() {
bail!("path: 后面要跟路径关键词,例如 `path:报告` 或 `path:\"我的 文档\"`");
}
g.paths.push(PathTerm { operand, phrase });
continue;
}
"mtime" => {
g.mtimes.push(parse_date_bound(rest)?);
continue;
}
"size" => {
g.sizes.push(parse_size_bound(rest)?);
continue;
}
_ => {}
}
}
g.content.push(tok.to_string());
}
Ok(g)
}
fn split_cmp(operand: &str) -> Result<(Cmp, &str)> {
if let Some(rest) = operand.strip_prefix(">=") {
Ok((Cmp::Ge, rest))
} else if let Some(rest) = operand.strip_prefix("<=") {
Ok((Cmp::Le, rest))
} else if let Some(rest) = operand.strip_prefix('>') {
Ok((Cmp::Gt, rest))
} else if let Some(rest) = operand.strip_prefix('<') {
Ok((Cmp::Lt, rest))
} else {
bail!(
"过滤条件 \"{operand}\" 缺少比较符,要写成 >、>=、< 或 <=,\
例如 mtime:>2026-01-01 或 size:<500kb"
)
}
}
fn parse_date_bound(operand: &str) -> Result<DateBound> {
let (cmp, date_str) = split_cmp(operand)?;
let parts: Vec<&str> = date_str.split('-').collect();
let bad = || format!("日期 \"{date_str}\" 格式不对,要写成 YYYY-MM-DD 或 YYYY-MM");
let parse_num =
|s: &str| -> Result<i64> { s.parse::<i64>().map_err(|_| anyhow::anyhow!(bad())) };
let (year, month, day, has_day) = match parts.as_slice() {
[y, m, d] => (parse_num(y)?, parse_num(m)?, parse_num(d)?, true),
[y, m] => (parse_num(y)?, parse_num(m)?, 1, false),
_ => bail!(bad()),
};
if !(1..=12).contains(&month) {
bail!("日期 \"{date_str}\" 的月份要在 1..=12 之间");
}
let dim = days_in_month(year, month as u32);
if has_day && !(1..=dim as i64).contains(&day) {
bail!("日期 \"{date_str}\" 的日要在 1..={dim} 之间");
}
let start_ms = date_to_ms(year, month as u32, day as u32);
let next_ms = if has_day {
start_ms + MS_PER_DAY
} else {
let (ny, nm) = if month == 12 {
(year + 1, 1)
} else {
(year, month as u32 + 1)
};
date_to_ms(ny, nm, 1)
};
Ok(DateBound {
cmp,
start_ms,
next_ms,
})
}
fn parse_size_bound(operand: &str) -> Result<SizeBound> {
let (cmp, rest) = split_cmp(operand)?;
let rest = rest.trim();
if rest.is_empty() {
bail!("size: 后面要跟体积,例如 size:>10mb 或 size:<500kb");
}
let split_at = rest
.find(|c: char| !(c.is_ascii_digit() || c == '.'))
.unwrap_or(rest.len());
let (num_str, unit_str) = rest.split_at(split_at);
let num: f64 = num_str
.parse()
.map_err(|_| anyhow::anyhow!("体积 \"{rest}\" 里的数字部分无法解析"))?;
if num < 0.0 {
bail!("体积不能是负数:\"{rest}\"");
}
let multiplier: f64 = match unit_str.trim().to_ascii_lowercase().as_str() {
"" | "b" => 1.0,
"k" | "kb" => 1024.0,
"m" | "mb" => 1024.0 * 1024.0,
"g" | "gb" => 1024.0 * 1024.0 * 1024.0,
other => bail!("不认识的体积单位 \"{other}\",只支持 kb / mb / gb(或省略按字节)"),
};
let bytes = (num * multiplier).round();
let bytes = if bytes >= u64::MAX as f64 {
u64::MAX
} else {
bytes as u64
};
Ok(SizeBound { cmp, bytes })
}
const MS_PER_DAY: i64 = 86_400_000;
fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 => {
if is_leap(year) {
29
} else {
28
}
}
_ => 30,
}
}
fn is_leap(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn date_to_ms(year: i64, month: u32, day: u32) -> i64 {
let m = month as i64;
let d = day as i64;
let y = if m <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days = era * 146097 + doe - 719468; days * MS_PER_DAY
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_operator_query_signals_passthrough() {
for q in ["限流 中间件", "\"unique marker\"", "分布式", "ext:md 限流"] {
let parsed = parse(q).unwrap();
assert!(!parsed.has_operators, "查询 {q:?} 不该被判成含操作符");
assert!(parsed.groups.is_empty());
}
}
#[test]
fn lowercase_or_and_not_are_plain_words() {
let parsed = parse("cats or dogs").unwrap();
assert!(!parsed.has_operators);
let parsed = parse("shall not pass").unwrap();
assert!(!parsed.has_operators);
}
#[test]
fn date_to_ms_epoch_and_known_dates() {
assert_eq!(date_to_ms(1970, 1, 1), 0);
assert_eq!(date_to_ms(1970, 1, 2), MS_PER_DAY);
assert_eq!(date_to_ms(2000, 1, 1), 10957 * MS_PER_DAY);
}
#[test]
fn parse_mtime_day_bounds() {
let b = parse_date_bound(">2026-01-01").unwrap();
assert_eq!(b.cmp, Cmp::Gt);
assert_eq!(b.start_ms, date_to_ms(2026, 1, 1));
assert_eq!(b.next_ms, date_to_ms(2026, 1, 2));
let b = parse_date_bound(">=2026-07").unwrap();
assert_eq!(b.cmp, Cmp::Ge);
assert_eq!(b.start_ms, date_to_ms(2026, 7, 1));
assert_eq!(b.next_ms, date_to_ms(2026, 8, 1));
let b = parse_date_bound("<2026-12").unwrap();
assert_eq!(b.next_ms, date_to_ms(2027, 1, 1));
}
#[test]
fn parse_mtime_rejects_garbage() {
assert!(parse_date_bound(">abc").is_err());
assert!(parse_date_bound(">2026-13-01").is_err(), "月份越界应报错");
assert!(parse_date_bound(">2026-02-30").is_err(), "2 月没有 30 号");
assert!(parse_date_bound("2026-01-01").is_err(), "缺比较符应报错");
}
#[test]
fn parse_size_units_case_insensitive() {
assert_eq!(parse_size_bound(">10mb").unwrap().bytes, 10 * 1024 * 1024);
assert_eq!(parse_size_bound("<500KB").unwrap().bytes, 500 * 1024);
assert_eq!(parse_size_bound(">=1Gb").unwrap().bytes, 1024 * 1024 * 1024);
assert_eq!(parse_size_bound(">1024").unwrap().bytes, 1024);
assert_eq!(
parse_size_bound(">1.5mb").unwrap().bytes,
(1.5 * 1024.0 * 1024.0) as u64
);
}
#[test]
fn parse_size_rejects_garbage() {
assert!(parse_size_bound(">abc").is_err());
assert!(parse_size_bound(">10tb").is_err(), "不支持的单位应报错");
assert!(parse_size_bound("10mb").is_err(), "缺比较符应报错");
}
#[test]
fn or_splits_into_groups_and_space_is_and_within_group() {
let parsed = parse("限流 中间件 OR 熔断").unwrap();
assert!(parsed.has_operators);
assert_eq!(parsed.groups.len(), 2, "OR 应切成两组");
assert_eq!(parsed.groups[0].content, vec!["限流", "中间件"]);
assert_eq!(parsed.groups[1].content, vec!["熔断"]);
}
#[test]
fn exclusion_via_dash_and_not() {
let parsed = parse("限流 -废弃").unwrap();
assert_eq!(parsed.groups[0].content, vec!["限流"]);
assert_eq!(parsed.groups[0].excluded, vec!["废弃"]);
let parsed = parse("限流 NOT 废弃").unwrap();
assert_eq!(parsed.groups[0].content, vec!["限流"]);
assert_eq!(parsed.groups[0].excluded, vec!["废弃"]);
}
#[test]
fn path_operand_quoted_vs_bare() {
let parsed = parse("path:报告").unwrap();
assert_eq!(
parsed.groups[0].paths,
vec![PathTerm {
operand: "报告".to_string(),
phrase: false
}]
);
let parsed = parse("path:\"my docs\"").unwrap();
assert_eq!(
parsed.groups[0].paths,
vec![PathTerm {
operand: "my docs".to_string(),
phrase: true
}]
);
}
#[test]
fn combined_operators_in_one_group() {
let parsed = parse("限流 path:src mtime:>2026-01-01 size:<1mb -草稿").unwrap();
let g = &parsed.groups[0];
assert_eq!(g.content, vec!["限流"]);
assert_eq!(g.excluded, vec!["草稿"]);
assert_eq!(g.paths.len(), 1);
assert_eq!(g.mtimes.len(), 1);
assert_eq!(g.sizes.len(), 1);
}
#[test]
fn ext_prefix_stays_content_for_backward_compat() {
let parsed = parse("ext:md -草稿").unwrap();
assert!(parsed.has_operators);
assert_eq!(parsed.groups[0].content, vec!["ext:md"]);
assert_eq!(parsed.groups[0].excluded, vec!["草稿"]);
}
#[test]
fn empty_operand_errors() {
assert!(parse("path:").is_err(), "path: 空操作数应报错");
assert!(parse("path:\"\"").is_err(), "path 引号里为空应报错");
}
#[test]
fn only_connectives_errors() {
assert!(parse("NOT").is_err());
}
}