rust: port filter
This commit is contained in:
Generated
+39
@@ -8,6 +8,15 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aho-corasick"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.2"
|
version = "2.13.2"
|
||||||
@@ -179,6 +188,7 @@ dependencies = [
|
|||||||
"crossterm",
|
"crossterm",
|
||||||
"flate2",
|
"flate2",
|
||||||
"libc",
|
"libc",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -264,6 +274,35 @@ dependencies = [
|
|||||||
"bitflags",
|
"bitflags",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex"
|
||||||
|
version = "1.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-automata",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-automata"
|
||||||
|
version = "0.4.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
|
||||||
|
dependencies = [
|
||||||
|
"aho-corasick",
|
||||||
|
"memchr",
|
||||||
|
"regex-syntax",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "regex-syntax"
|
||||||
|
version = "0.8.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.44"
|
version = "0.38.44"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ description = "Interactive terminal disk usage analyser (Rust port)"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
regex = "1"
|
||||||
crossbeam-channel = "0.5"
|
crossbeam-channel = "0.5"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use super::eval::Ctx;
|
||||||
|
use super::parser::Query;
|
||||||
|
use crate::cancel::Cancel;
|
||||||
|
use crate::inventory::{NodeId, SizeMode, Tree, FLAG_HARDLINK_DUP};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Per-node filtered sizes: the bytes of matching content beneath each node.
|
||||||
|
/// A matching directory contributes its whole subtree (once, even if
|
||||||
|
/// descendants match too). matched marks every node satisfying the query.
|
||||||
|
/// Nodes added after the result was computed (live scan) have no entry and
|
||||||
|
/// are treated as not matching until the filter is re-applied.
|
||||||
|
pub struct FilterResult {
|
||||||
|
pub query: Arc<Query>,
|
||||||
|
pub mode: SizeMode,
|
||||||
|
pub sizes: Vec<i64>, // per NodeId; len = tree size at evaluation time
|
||||||
|
pub matched: Vec<bool>, // node itself matched
|
||||||
|
pub count: i64, // number of matching nodes
|
||||||
|
pub total: i64,
|
||||||
|
pub elapsed: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilterResult {
|
||||||
|
/// The filtered size of id (0 if out of range).
|
||||||
|
pub fn size(&self, id: NodeId) -> i64 {
|
||||||
|
self.sizes.get(id as usize).copied().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether id itself matched.
|
||||||
|
pub fn is_match(&self, id: NodeId) -> bool {
|
||||||
|
self.matched.get(id as usize).copied().unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluates q over the whole tree. The caller holds the read lock. It relies
|
||||||
|
/// on parents having smaller IDs than their children. None if cancelled.
|
||||||
|
pub fn apply(cancel: &Cancel, t: &Tree, q: Arc<Query>, m: SizeMode) -> Option<FilterResult> {
|
||||||
|
let start = Instant::now();
|
||||||
|
let n = t.len();
|
||||||
|
let mut sizes = vec![0i64; n];
|
||||||
|
let mut matched = vec![false; n];
|
||||||
|
let mut covered = vec![false; n];
|
||||||
|
let mut count = 0;
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as i64);
|
||||||
|
let mut c = Ctx::new(t, 0, now);
|
||||||
|
if q.uses_path {
|
||||||
|
c.dir_paths = Some(HashMap::new());
|
||||||
|
}
|
||||||
|
for i in 0..n {
|
||||||
|
if i & 0xffff == 0 && cancel.is_cancelled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let id = i as NodeId;
|
||||||
|
let nd = t.node(id);
|
||||||
|
(c.id, c.node, c.path) = (id, nd, None);
|
||||||
|
if c.dir_paths.is_some() && nd.is_dir() {
|
||||||
|
let p = c.lower_path();
|
||||||
|
c.dir_paths.as_mut().unwrap().insert(id, p.clone());
|
||||||
|
c.path = Some(p);
|
||||||
|
}
|
||||||
|
if i > 0 && q.matches(&mut c) {
|
||||||
|
matched[i] = true;
|
||||||
|
covered[i] = true;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
if i > 0 && covered[nd.parent as usize] {
|
||||||
|
covered[i] = true;
|
||||||
|
}
|
||||||
|
if covered[i] && nd.flags & FLAG_HARDLINK_DUP == 0 {
|
||||||
|
sizes[i] = nd.own(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i in (1..n).rev() {
|
||||||
|
let p = t.node(i as NodeId).parent as usize;
|
||||||
|
sizes[p] = sizes[p].saturating_add(sizes[i]);
|
||||||
|
}
|
||||||
|
let total = sizes.first().copied().unwrap_or(0);
|
||||||
|
Some(FilterResult { query: q, mode: m, sizes, matched, count, total, elapsed: start.elapsed() })
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
use super::parser::{Field, Query};
|
||||||
|
use crate::inventory::{Flags, Node, NodeId, Tree};
|
||||||
|
use crate::platform;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// The evaluation context for one node. The tree must be read-locked.
|
||||||
|
pub struct Ctx<'a> {
|
||||||
|
pub tree: &'a Tree,
|
||||||
|
pub id: NodeId,
|
||||||
|
pub node: &'a Node,
|
||||||
|
pub now: i64, // unix nanoseconds
|
||||||
|
pub(super) path: Option<String>,
|
||||||
|
// dir_paths caches lower-cased directory paths during apply, which visits
|
||||||
|
// parents before children, so a node's path is one concatenation.
|
||||||
|
pub(super) dir_paths: Option<HashMap<NodeId, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Ctx<'a> {
|
||||||
|
pub fn new(tree: &'a Tree, id: NodeId, now: i64) -> Ctx<'a> {
|
||||||
|
Ctx { tree, id, node: tree.node(id), now, path: None, dir_paths: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn str(&mut self, f: Field) -> Cow<'_, str> {
|
||||||
|
let n = self.node;
|
||||||
|
match f {
|
||||||
|
Field::Name => n.name_str().to_lowercase().into(),
|
||||||
|
Field::Path => {
|
||||||
|
if self.path.is_none() {
|
||||||
|
self.path = Some(self.lower_path());
|
||||||
|
}
|
||||||
|
self.path.as_deref().unwrap().into()
|
||||||
|
}
|
||||||
|
Field::Ext => self.tree.ext_name(n).into(),
|
||||||
|
Field::Type => n.kind.name().to_lowercase().into(),
|
||||||
|
Field::Category if n.is_dir() => "directory".into(),
|
||||||
|
Field::Category => n.cat.name().to_lowercase().into(),
|
||||||
|
Field::Owner => platform::user_name(n.uid).to_lowercase().into(),
|
||||||
|
Field::Group => platform::group_name(n.gid).to_lowercase().into(),
|
||||||
|
_ => "".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(&self, f: Field) -> i64 {
|
||||||
|
let n = self.node;
|
||||||
|
match f {
|
||||||
|
Field::Size => n.tot_size,
|
||||||
|
Field::Allocated => n.tot_alloc,
|
||||||
|
Field::Age => self.now.saturating_sub(n.mtime),
|
||||||
|
Field::Modified => n.mtime,
|
||||||
|
Field::Files => n.files as i64,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn lower_path(&self) -> String {
|
||||||
|
if let Some(dp) = &self.dir_paths {
|
||||||
|
if let Some(p) = dp.get(&self.id) {
|
||||||
|
return p.clone();
|
||||||
|
}
|
||||||
|
if let Some(pp) = dp.get(&self.node.parent) {
|
||||||
|
let sep = if pp.ends_with('/') { "" } else { "/" };
|
||||||
|
return format!("{pp}{sep}{}", self.node.name_str().to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.tree.path_string(self.id).to_lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub(super) enum NumOp {
|
||||||
|
Eq,
|
||||||
|
Ne,
|
||||||
|
Gt,
|
||||||
|
Ge,
|
||||||
|
Lt,
|
||||||
|
Le,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A compiled query expression.
|
||||||
|
pub(super) enum Expr {
|
||||||
|
And(Box<Expr>, Box<Expr>),
|
||||||
|
Or(Box<Expr>, Box<Expr>),
|
||||||
|
Not(Box<Expr>),
|
||||||
|
Contains(Field, String),
|
||||||
|
Glob(Field, String),
|
||||||
|
Re(Field, Regex),
|
||||||
|
Eq(Field, Vec<String>),
|
||||||
|
Flag(Flags), // any of these flags set
|
||||||
|
Num(Field, NumOp, Vec<i64>),
|
||||||
|
False, // malformed bare glob
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Expr {
|
||||||
|
fn eval(&self, c: &mut Ctx) -> bool {
|
||||||
|
match self {
|
||||||
|
Expr::And(l, r) => l.eval(c) && r.eval(c),
|
||||||
|
Expr::Or(l, r) => l.eval(c) || r.eval(c),
|
||||||
|
Expr::Not(n) => !n.eval(c),
|
||||||
|
Expr::Contains(f, sub) => c.str(*f).contains(sub.as_str()),
|
||||||
|
Expr::Glob(f, pat) => platform::glob_match(pat.as_bytes(), c.str(*f).as_bytes()),
|
||||||
|
Expr::Re(f, re) => re.is_match(&c.str(*f)),
|
||||||
|
Expr::Eq(f, set) => {
|
||||||
|
let v = c.str(*f);
|
||||||
|
set.iter().any(|s| *s == v)
|
||||||
|
}
|
||||||
|
Expr::Flag(mask) => c.node.flags & mask != 0,
|
||||||
|
Expr::Num(f, op, vals) => {
|
||||||
|
if matches!(f, Field::Age | Field::Modified) && c.node.mtime == 0 {
|
||||||
|
return false; // unknown time never matches
|
||||||
|
}
|
||||||
|
let v = c.num(*f);
|
||||||
|
vals.iter().any(|&x| match op {
|
||||||
|
NumOp::Eq => v == x,
|
||||||
|
NumOp::Ne => v != x,
|
||||||
|
NumOp::Gt => v > x,
|
||||||
|
NumOp::Ge => v >= x,
|
||||||
|
NumOp::Lt => v < x,
|
||||||
|
NumOp::Le => v <= x,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Expr::False => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Query {
|
||||||
|
/// Evaluates the query against one node.
|
||||||
|
pub fn matches(&self, c: &mut Ctx) -> bool {
|
||||||
|
self.root.eval(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub(super) enum TokKind {
|
||||||
|
Eof,
|
||||||
|
Word,
|
||||||
|
Str,
|
||||||
|
Op,
|
||||||
|
LParen,
|
||||||
|
RParen,
|
||||||
|
Comma,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(super) struct Token {
|
||||||
|
pub kind: TokKind,
|
||||||
|
pub val: String,
|
||||||
|
pub pos: usize, // byte offset
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tok(kind: TokKind, val: &str, pos: usize) -> Token {
|
||||||
|
Token { kind, val: val.to_string(), pos }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASCII white space as Go's unicode.IsSpace sees it.
|
||||||
|
fn is_space(c: u8) -> bool {
|
||||||
|
matches!(c, b' ' | b'\t' | b'\n' | b'\x0b' | b'\x0c' | b'\r')
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn lex(s: &str) -> Result<Vec<Token>, String> {
|
||||||
|
use TokKind::*;
|
||||||
|
let b = s.as_bytes();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < b.len() {
|
||||||
|
let c = b[i];
|
||||||
|
match c {
|
||||||
|
// All of is_space, not just " \t\n\r": a word can't start on a
|
||||||
|
// space byte, so anything else here would never advance.
|
||||||
|
_ if is_space(c) => i += 1,
|
||||||
|
b'(' => {
|
||||||
|
out.push(tok(LParen, "(", i));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
b')' => {
|
||||||
|
out.push(tok(RParen, ")", i));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
b',' => {
|
||||||
|
out.push(tok(Comma, ",", i));
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
b'"' | b'\'' => {
|
||||||
|
let start = i;
|
||||||
|
i += 1;
|
||||||
|
let mut v = Vec::new();
|
||||||
|
while i < b.len() && b[i] != c {
|
||||||
|
if b[i] == b'\\' && i + 1 < b.len() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
v.push(b[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if i >= b.len() {
|
||||||
|
return Err(format!("unterminated string at {}", start + 1));
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
// Only ASCII bytes were dropped, so v is still valid UTF-8.
|
||||||
|
out.push(Token { kind: Str, val: String::from_utf8_lossy(&v).into_owned(), pos: start });
|
||||||
|
}
|
||||||
|
b'=' | b'!' | b'<' | b'>' | b'~' | b'&' | b'|' => {
|
||||||
|
let start = i;
|
||||||
|
let n = b.get(i + 1).copied();
|
||||||
|
let two = n == Some(b'=') || (c == b'&' && n == Some(b'&')) || (c == b'|' && n == Some(b'|'));
|
||||||
|
let op = &s[i..i + 1 + two as usize];
|
||||||
|
i += op.len();
|
||||||
|
match op {
|
||||||
|
"&&" => out.push(tok(Word, "AND", start)),
|
||||||
|
"||" => out.push(tok(Word, "OR", start)),
|
||||||
|
"!" => out.push(tok(Word, "NOT", start)),
|
||||||
|
"&" | "|" => return Err(format!("unexpected {op:?} at {}", start + 1)),
|
||||||
|
"==" => out.push(tok(Op, "=", start)),
|
||||||
|
_ => out.push(tok(Op, op, start)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let start = i;
|
||||||
|
while i < b.len() && !is_space(b[i]) && !b"()=!<>~,\"'&|".contains(&b[i]) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out.push(tok(Word, &s[start..i], start));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(tok(Eof, "", s.len()));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! The search/filter query language:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! query := or
|
||||||
|
//! or := and { OR and }
|
||||||
|
//! and := unary { [AND] unary } (juxtaposition means AND)
|
||||||
|
//! unary := NOT unary | "(" query ")" | cmp | word
|
||||||
|
//! cmp := field op value
|
||||||
|
//! op := = | != | > | >= | < | <= | contains | matches | in | ~
|
||||||
|
//! value := word | "quoted string" | "(" value { "," value } ")"
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! A bare word matches the name: substring (case-insensitive), or a glob if it
|
||||||
|
//! contains * ? or [. Parsing is independent of the UI and fully testable.
|
||||||
|
|
||||||
|
mod apply;
|
||||||
|
mod eval;
|
||||||
|
mod lexer;
|
||||||
|
mod parser;
|
||||||
|
|
||||||
|
pub use apply::{apply, FilterResult};
|
||||||
|
pub use eval::Ctx;
|
||||||
|
pub use parser::{parse, parse_age, parse_size, Field, Query, FIELDS};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cancel::Cancel;
|
||||||
|
use crate::inventory::{category_for, ext, Kind, NodeId, SizeMode, Tree};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const HOUR: Duration = Duration::from_secs(3600);
|
||||||
|
const DAY: Duration = Duration::from_secs(86400);
|
||||||
|
|
||||||
|
fn tree() -> Tree {
|
||||||
|
let mut t = Tree::new(b"/srv", Kind::Dir);
|
||||||
|
let vms = t.add(0, b"vms", Kind::Dir);
|
||||||
|
let cache = t.add(0, b"cache", Kind::Dir);
|
||||||
|
let mut add = |p: NodeId, name: &str, size: i64, age: Duration| {
|
||||||
|
let id = t.add(p, name.as_bytes(), Kind::File);
|
||||||
|
let n = t.node_mut(id);
|
||||||
|
n.size = size;
|
||||||
|
n.alloc = size;
|
||||||
|
n.mtime = (SystemTime::now() - age).duration_since(UNIX_EPOCH).unwrap().as_nanos() as i64;
|
||||||
|
n.cat = category_for(&ext(name.as_bytes()), false, false);
|
||||||
|
};
|
||||||
|
add(vms, "ubuntu.qcow2", 80 << 30, 400 * DAY);
|
||||||
|
add(vms, "win.vmdk", 40 << 30, HOUR);
|
||||||
|
add(vms, "notes.txt", 1000, HOUR);
|
||||||
|
add(cache, "blob.iso", 700 << 20, 10 * DAY);
|
||||||
|
add(cache, "x.tmp", 10, HOUR);
|
||||||
|
t.recompute();
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(tr: &Tree, q: &str) -> HashSet<String> {
|
||||||
|
let pq = parse(q, SystemTime::now()).unwrap_or_else(|e| panic!("parse({q:?}): {e}"));
|
||||||
|
let r = apply(&Cancel::new(), tr, Arc::new(pq), SizeMode::Logical).unwrap();
|
||||||
|
(0..tr.len() as NodeId).filter(|&i| r.is_match(i)).map(|i| tr.node(i).name_str().into_owned()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queries() {
|
||||||
|
let tr = tree();
|
||||||
|
let cases: &[(&str, &[&str])] = &[
|
||||||
|
("ubuntu", &["ubuntu.qcow2"]),
|
||||||
|
("*.iso", &["blob.iso"]),
|
||||||
|
("size > 1GB", &["vms", "ubuntu.qcow2", "win.vmdk"]),
|
||||||
|
("size > 1GB AND type = file", &["ubuntu.qcow2", "win.vmdk"]),
|
||||||
|
("ext = qcow2", &["ubuntu.qcow2"]),
|
||||||
|
("size > 500MB AND ext IN (iso,qcow2,vmdk)", &["ubuntu.qcow2", "win.vmdk", "blob.iso"]),
|
||||||
|
("path contains cache", &["cache", "blob.iso", "x.tmp"]),
|
||||||
|
("age > 365d", &["ubuntu.qcow2"]),
|
||||||
|
("NOT type = dir AND size < 1k", &["notes.txt", "x.tmp"]),
|
||||||
|
("(ext = iso OR ext = tmp) type=file", &["blob.iso", "x.tmp"]),
|
||||||
|
("category = vm", &["ubuntu.qcow2", "win.vmdk", "blob.iso"]),
|
||||||
|
("name matches '^w.*k$'", &["win.vmdk"]),
|
||||||
|
("ext != qcow2 && type = file && size > 1GB", &["win.vmdk"]),
|
||||||
|
("name = 'notes.txt'", &["notes.txt"]),
|
||||||
|
];
|
||||||
|
for (q, want) in cases {
|
||||||
|
let want: HashSet<String> = want.iter().map(|s| s.to_string()).collect();
|
||||||
|
assert_eq!(names(&tr, q), want, "{q:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filtered_sizes_no_double_count() {
|
||||||
|
let tr = tree();
|
||||||
|
let q = parse("name = vms OR ext = qcow2", SystemTime::now()).unwrap();
|
||||||
|
let r = apply(&Cancel::new(), &tr, Arc::new(q), SizeMode::Logical).unwrap();
|
||||||
|
assert_eq!(r.total, tr.node(1).tot_size, "matching dir must not double count matching child");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_errors() {
|
||||||
|
for q in [
|
||||||
|
"", "size >", "size > banana", "(ext = iso", "ext = iso)", "size contains 5",
|
||||||
|
"name matches '['", "type = blob", "'unterminated", "a & b", "category = nope",
|
||||||
|
] {
|
||||||
|
assert!(parse(q, SystemTime::now()).is_err(), "parse({q:?}) should fail");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dates_globs_and_non_ascii() {
|
||||||
|
let tr = tree();
|
||||||
|
assert_eq!(names(&tr, "modified < 2000-01-01"), HashSet::new());
|
||||||
|
assert_eq!(names(&tr, "modified > '2000-01-01 9:30'").len(), 5);
|
||||||
|
assert_eq!(names(&tr, "modified > 2000-01-01T00:00:00.5+01:00").len(), 5);
|
||||||
|
assert_eq!(names(&tr, "modified > 30d").len(), 4);
|
||||||
|
for bad in ["modified > 2024-02-30", "modified > 2024-01-31T10:00", "ext = 'a['"] {
|
||||||
|
assert!(parse(bad, SystemTime::now()).is_ok() == bad.ends_with("10:00"), "{bad}");
|
||||||
|
}
|
||||||
|
assert!(names(&tr, "[").is_empty()); // malformed bare glob matches nothing
|
||||||
|
assert!(names(&tr, "voilà\u{a0}x").is_empty()); // Go's lexer hangs on byte 0xA0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_size_units() {
|
||||||
|
for (input, want) in [("1GB", 1_000_000_000), ("1GiB", 1 << 30), ("1.5k", 1536), ("500MB", 500_000_000), ("10", 10)] {
|
||||||
|
assert_eq!(parse_size(input), Ok(want), "{input}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
use super::eval::{Expr, NumOp};
|
||||||
|
use super::lexer::{lex, TokKind, Token};
|
||||||
|
use crate::inventory::{self as inv, Flags};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// A filterable attribute.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Field {
|
||||||
|
Name,
|
||||||
|
Path,
|
||||||
|
Ext,
|
||||||
|
Type,
|
||||||
|
Category,
|
||||||
|
Size,
|
||||||
|
Allocated,
|
||||||
|
Age,
|
||||||
|
Modified,
|
||||||
|
Owner,
|
||||||
|
Group,
|
||||||
|
Files,
|
||||||
|
Flag,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_by_name(s: &str) -> Option<Field> {
|
||||||
|
use Field::*;
|
||||||
|
Some(match s {
|
||||||
|
"name" => Name,
|
||||||
|
"path" => Path,
|
||||||
|
"ext" | "extension" => Ext,
|
||||||
|
"type" | "kind" => Type,
|
||||||
|
"category" | "cat" => Category,
|
||||||
|
"size" => Size,
|
||||||
|
"allocated" | "alloc" => Allocated,
|
||||||
|
"age" => Age,
|
||||||
|
"modified" | "mtime" => Modified,
|
||||||
|
"owner" | "user" => Owner,
|
||||||
|
"group" => Group,
|
||||||
|
"files" => Files,
|
||||||
|
"flag" | "is" => Flag,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Field names for help text.
|
||||||
|
pub const FIELDS: &str = "name path ext type category size allocated age modified owner group files flag";
|
||||||
|
|
||||||
|
/// A compiled filter.
|
||||||
|
pub struct Query {
|
||||||
|
src: String,
|
||||||
|
pub(super) root: Expr,
|
||||||
|
pub uses_path: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Query {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Parser {
|
||||||
|
toks: Vec<Token>,
|
||||||
|
i: usize,
|
||||||
|
uses_path: bool,
|
||||||
|
now: SystemTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compiles a query. now anchors relative ages (pass SystemTime::now()).
|
||||||
|
pub fn parse(s: &str, now: SystemTime) -> Result<Query, String> {
|
||||||
|
let toks = lex(s)?;
|
||||||
|
let mut p = Parser { toks, i: 0, uses_path: false, now };
|
||||||
|
if p.peek().kind == TokKind::Eof {
|
||||||
|
return Err("empty query".into());
|
||||||
|
}
|
||||||
|
let root = p.or()?;
|
||||||
|
let t = p.peek();
|
||||||
|
if t.kind != TokKind::Eof {
|
||||||
|
return Err(format!("unexpected {:?} at {}", t.val, t.pos + 1));
|
||||||
|
}
|
||||||
|
Ok(Query { src: s.to_string(), root, uses_path: p.uses_path })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_kw(t: &Token, kw: &str) -> bool {
|
||||||
|
t.kind == TokKind::Word && t.val.eq_ignore_ascii_case(kw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_value(t: &Token) -> bool {
|
||||||
|
t.kind == TokKind::Word || t.kind == TokKind::Str
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parser {
|
||||||
|
fn peek(&self) -> &Token {
|
||||||
|
&self.toks[self.i]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next(&mut self) -> Token {
|
||||||
|
let t = self.toks[self.i].clone();
|
||||||
|
if t.kind != TokKind::Eof {
|
||||||
|
self.i += 1;
|
||||||
|
}
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn or(&mut self) -> Result<Expr, String> {
|
||||||
|
let mut l = self.and()?;
|
||||||
|
while is_kw(self.peek(), "OR") {
|
||||||
|
self.next();
|
||||||
|
let r = self.and()?;
|
||||||
|
l = Expr::Or(Box::new(l), Box::new(r));
|
||||||
|
}
|
||||||
|
Ok(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn and(&mut self) -> Result<Expr, String> {
|
||||||
|
let mut l = self.unary()?;
|
||||||
|
loop {
|
||||||
|
let t = self.peek();
|
||||||
|
if is_kw(t, "AND") {
|
||||||
|
self.next();
|
||||||
|
} else if matches!(t.kind, TokKind::Eof | TokKind::RParen) || is_kw(t, "OR") {
|
||||||
|
return Ok(l);
|
||||||
|
}
|
||||||
|
let r = self.unary()?;
|
||||||
|
l = Expr::And(Box::new(l), Box::new(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unary(&mut self) -> Result<Expr, String> {
|
||||||
|
let t = self.peek().clone();
|
||||||
|
if is_kw(&t, "NOT") {
|
||||||
|
self.next();
|
||||||
|
return Ok(Expr::Not(Box::new(self.unary()?)));
|
||||||
|
}
|
||||||
|
if t.kind == TokKind::LParen {
|
||||||
|
self.next();
|
||||||
|
let n = self.or()?;
|
||||||
|
if self.next().kind != TokKind::RParen {
|
||||||
|
return Err(format!("missing ) for ( at {}", t.pos + 1));
|
||||||
|
}
|
||||||
|
return Ok(n);
|
||||||
|
}
|
||||||
|
if is_value(&t) {
|
||||||
|
self.next();
|
||||||
|
if let Some(f) = field_by_name(&t.val.to_lowercase()) {
|
||||||
|
if t.kind == TokKind::Word && self.is_op(self.peek()) {
|
||||||
|
let op = self.next();
|
||||||
|
return self.cmp(f, op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(name_match(&t.val));
|
||||||
|
}
|
||||||
|
Err(format!("unexpected {:?} at {}", t.val, t.pos + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_op(&self, t: &Token) -> bool {
|
||||||
|
t.kind == TokKind::Op || is_kw(t, "contains") || is_kw(t, "matches") || is_kw(t, "in")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn values(&mut self) -> Result<Vec<Token>, String> {
|
||||||
|
if self.peek().kind != TokKind::LParen {
|
||||||
|
let t = self.next();
|
||||||
|
if !is_value(&t) {
|
||||||
|
return Err(format!("expected value at {}", t.pos + 1));
|
||||||
|
}
|
||||||
|
return Ok(vec![t]);
|
||||||
|
}
|
||||||
|
self.next();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
loop {
|
||||||
|
let t = self.next();
|
||||||
|
if !is_value(&t) {
|
||||||
|
return Err(format!("expected value at {}", t.pos + 1));
|
||||||
|
}
|
||||||
|
out.push(t);
|
||||||
|
let n = self.next();
|
||||||
|
match n.kind {
|
||||||
|
TokKind::Comma => {}
|
||||||
|
TokKind::RParen => return Ok(out),
|
||||||
|
_ => return Err(format!("expected , or ) at {}", n.pos + 1)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmp(&mut self, f: Field, op_tok: Token) -> Result<Expr, String> {
|
||||||
|
let mut op = op_tok.val.to_lowercase();
|
||||||
|
if op == "~" {
|
||||||
|
op = "matches".into();
|
||||||
|
}
|
||||||
|
let vals = self.values()?;
|
||||||
|
if op != "in" && vals.len() != 1 {
|
||||||
|
return Err(format!("operator {op} takes one value"));
|
||||||
|
}
|
||||||
|
if f == Field::Path {
|
||||||
|
self.uses_path = true;
|
||||||
|
}
|
||||||
|
if matches!(f, Field::Size | Field::Allocated | Field::Age | Field::Modified | Field::Files) {
|
||||||
|
let nop = match op.as_str() {
|
||||||
|
"=" | "in" => NumOp::Eq,
|
||||||
|
"!=" => NumOp::Ne,
|
||||||
|
">" => NumOp::Gt,
|
||||||
|
">=" => NumOp::Ge,
|
||||||
|
"<" => NumOp::Lt,
|
||||||
|
"<=" => NumOp::Le,
|
||||||
|
_ => return Err(format!("{op} cannot be used with numeric field")),
|
||||||
|
};
|
||||||
|
let nums = vals.iter().map(|v| self.number(f, &v.val)).collect::<Result<Vec<_>, _>>()?;
|
||||||
|
return Ok(Expr::Num(f, nop, nums));
|
||||||
|
}
|
||||||
|
// String-like fields.
|
||||||
|
match op.as_str() {
|
||||||
|
"contains" => Ok(Expr::Contains(f, vals[0].val.to_lowercase())),
|
||||||
|
"matches" => match Regex::new(&format!("(?i){}", vals[0].val)) {
|
||||||
|
Ok(re) => Ok(Expr::Re(f, re)),
|
||||||
|
Err(e) => Err(format!("bad regular expression: {e}")),
|
||||||
|
},
|
||||||
|
"=" | "!=" | "in" => {
|
||||||
|
let mut set = Vec::new();
|
||||||
|
for v in &vals {
|
||||||
|
let mut s = v.val.to_lowercase();
|
||||||
|
match f {
|
||||||
|
Field::Ext => s = s.strip_prefix('.').unwrap_or(&s).to_string(),
|
||||||
|
Field::Category => match inv::parse_category(&s) {
|
||||||
|
Some(c) => s = c.name().to_lowercase(),
|
||||||
|
None => return Err(format!("unknown category {:?}", v.val)),
|
||||||
|
},
|
||||||
|
Field::Type => s = norm_type(&s)?.to_string(),
|
||||||
|
Field::Flag if flag_bit(&s).is_none() => {
|
||||||
|
return Err(format!(
|
||||||
|
"unknown flag {:?} (sparse, hardlink, error, mount, incomplete, followed, broken)",
|
||||||
|
v.val
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
set.push(s);
|
||||||
|
}
|
||||||
|
let n = if f == Field::Flag {
|
||||||
|
Expr::Flag(set.iter().filter_map(|s| flag_bit(s)).fold(0, |a, b| a | b))
|
||||||
|
} else if set.len() == 1 && has_glob(&set[0]) {
|
||||||
|
if !glob_ok(set[0].as_bytes()) {
|
||||||
|
return Err(format!("bad pattern {:?}", vals[0].val));
|
||||||
|
}
|
||||||
|
Expr::Glob(f, set.pop().unwrap())
|
||||||
|
} else {
|
||||||
|
Expr::Eq(f, set)
|
||||||
|
};
|
||||||
|
Ok(if op == "!=" { Expr::Not(Box::new(n)) } else { n })
|
||||||
|
}
|
||||||
|
_ => Err(format!("operator {op} not valid for {}", op_tok.val)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses sizes ("1.5GiB", "500MB", "10k"), ages ("365d", "2w") and dates
|
||||||
|
/// ("2024-01-31"). Ages and dates are converted to modification times
|
||||||
|
/// (unix nanoseconds) relative to self.now.
|
||||||
|
fn number(&self, f: Field, s: &str) -> Result<i64, String> {
|
||||||
|
match f {
|
||||||
|
Field::Size | Field::Allocated => parse_size(s),
|
||||||
|
Field::Files => s.parse::<i64>().map_err(|e| format!("bad number {s:?}: {e}")),
|
||||||
|
Field::Age => parse_age(s).map(nanos),
|
||||||
|
Field::Modified => {
|
||||||
|
if let Some(t) = parse_date(s) {
|
||||||
|
return Ok(t);
|
||||||
|
}
|
||||||
|
if let Ok(d) = parse_age(s) {
|
||||||
|
let now = self.now.duration_since(UNIX_EPOCH).map(nanos).unwrap_or(0);
|
||||||
|
return Ok(now.saturating_sub(nanos(d)));
|
||||||
|
}
|
||||||
|
Err(format!("bad date {s:?} (use YYYY-MM-DD or an age like 30d)"))
|
||||||
|
}
|
||||||
|
_ => Err("not numeric".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nanos(d: Duration) -> i64 {
|
||||||
|
d.as_nanos().min(i64::MAX as u128) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_glob(s: &str) -> bool {
|
||||||
|
s.contains(['*', '?', '['])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn name_match(v: &str) -> Expr {
|
||||||
|
let lv = v.to_lowercase();
|
||||||
|
if has_glob(v) {
|
||||||
|
// Go's filepath.Match never matches a malformed pattern.
|
||||||
|
return if glob_ok(lv.as_bytes()) { Expr::Glob(Field::Name, lv) } else { Expr::False };
|
||||||
|
}
|
||||||
|
Expr::Contains(Field::Name, lv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports whether pattern is well formed by Go's filepath.Match rules
|
||||||
|
/// (fnmatch, which does the matching, accepts anything).
|
||||||
|
fn glob_ok(p: &[u8]) -> bool {
|
||||||
|
// One possibly escaped character of a range; None if malformed.
|
||||||
|
let esc = |mut i: usize| -> Option<usize> {
|
||||||
|
match p.get(i)? {
|
||||||
|
b'-' | b']' => return None,
|
||||||
|
b'\\' => {
|
||||||
|
i += 1;
|
||||||
|
p.get(i)?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Some(i + 1)
|
||||||
|
};
|
||||||
|
let mut i = 0;
|
||||||
|
while i < p.len() {
|
||||||
|
match p[i] {
|
||||||
|
b'\\' => {
|
||||||
|
if i + 1 >= p.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
b'[' => {
|
||||||
|
i += 1;
|
||||||
|
if p.get(i) == Some(&b'^') {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let mut n = 0;
|
||||||
|
loop {
|
||||||
|
if p.get(i) == Some(&b']') && n > 0 {
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(j) = esc(i) else { return false };
|
||||||
|
i = j;
|
||||||
|
if p.get(i) == Some(&b'-') {
|
||||||
|
let Some(j) = esc(i + 1) else { return false };
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => i += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn norm_type(s: &str) -> Result<&'static str, String> {
|
||||||
|
match s {
|
||||||
|
"f" | "file" => Ok("file"),
|
||||||
|
"d" | "dir" | "directory" => Ok("directory"),
|
||||||
|
"l" | "link" | "symlink" => Ok("symlink"),
|
||||||
|
"other" | "special" => Ok("special"),
|
||||||
|
_ => Err(format!("unknown type {s:?} (file, dir, symlink, other)")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flag_bit(s: &str) -> Option<Flags> {
|
||||||
|
Some(match s {
|
||||||
|
"sparse" => inv::FLAG_SPARSE,
|
||||||
|
"hardlink" | "hardlinked" => inv::FLAG_HARDLINKED,
|
||||||
|
"error" => inv::FLAG_ERROR,
|
||||||
|
"mount" => inv::FLAG_MOUNT_POINT,
|
||||||
|
"incomplete" => inv::FLAG_INCOMPLETE,
|
||||||
|
"followed" => inv::FLAG_FOLLOWED,
|
||||||
|
"broken" => inv::FLAG_BROKEN_LINK,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a leading run of digits and dots from s.
|
||||||
|
fn split_num(s: &str) -> (&str, &str) {
|
||||||
|
let i = s.bytes().position(|c| !(c.is_ascii_digit() || c == b'.')).unwrap_or(s.len());
|
||||||
|
s.split_at(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a size with optional unit. kB/MB/GB/TB are SI (powers of 1000);
|
||||||
|
/// KiB/MiB/GiB/TiB are IEC (1024). Bare K/M/G/T follow the display setting
|
||||||
|
/// (textutil::set_si).
|
||||||
|
pub fn parse_size(s: &str) -> Result<i64, String> {
|
||||||
|
let (num, unit_raw) = split_num(s);
|
||||||
|
let Ok(v) = num.parse::<f64>() else { return Err(format!("bad size {s:?}")) };
|
||||||
|
// ponytail: textutil exposes no SI getter; its formatting reveals the setting.
|
||||||
|
let bare: f64 = if crate::textutil::size(1000).ends_with("kB") { 1000.0 } else { 1024.0 };
|
||||||
|
let m = match unit_raw.trim().to_lowercase().as_str() {
|
||||||
|
"" | "b" => 1.0,
|
||||||
|
"k" => bare,
|
||||||
|
"m" => bare.powi(2),
|
||||||
|
"g" => bare.powi(3),
|
||||||
|
"t" => bare.powi(4),
|
||||||
|
"p" => bare.powi(5),
|
||||||
|
"kb" => 1e3,
|
||||||
|
"mb" => 1e6,
|
||||||
|
"gb" => 1e9,
|
||||||
|
"tb" => 1e12,
|
||||||
|
"pb" => 1e15,
|
||||||
|
"kib" => (1u64 << 10) as f64,
|
||||||
|
"mib" => (1u64 << 20) as f64,
|
||||||
|
"gib" => (1u64 << 30) as f64,
|
||||||
|
"tib" => (1u64 << 40) as f64,
|
||||||
|
"pib" => (1u64 << 50) as f64,
|
||||||
|
_ => return Err(format!("unknown size unit {unit_raw:?}")),
|
||||||
|
};
|
||||||
|
let r = v * m;
|
||||||
|
if r > 9e18 {
|
||||||
|
return Err(format!("size {s:?} too large"));
|
||||||
|
}
|
||||||
|
Ok(r as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y.
|
||||||
|
pub fn parse_age(s: &str) -> Result<Duration, String> {
|
||||||
|
let (num, unit) = split_num(s);
|
||||||
|
let Ok(v) = num.parse::<f64>() else { return Err(format!("bad age {s:?}")) };
|
||||||
|
const DAY: f64 = 86400.0;
|
||||||
|
let secs = match unit.to_lowercase().as_str() {
|
||||||
|
"s" | "sec" => 1.0,
|
||||||
|
"min" => 60.0,
|
||||||
|
"h" => 3600.0,
|
||||||
|
"d" | "" => DAY,
|
||||||
|
"w" => 7.0 * DAY,
|
||||||
|
"m" | "mo" => 30.0 * DAY,
|
||||||
|
"y" => 365.0 * DAY,
|
||||||
|
_ => return Err(format!("unknown age unit {unit:?} (s, min, h, d, w, mo, y)")),
|
||||||
|
};
|
||||||
|
// Saturates where Go's time.Duration would overflow.
|
||||||
|
Ok(Duration::from_nanos((v * secs * 1e9) as u64))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses "2006-01-02", "2006-01-02T15:04", "2006-01-02 15:04" (local time)
|
||||||
|
/// or RFC 3339, as Go's time.ParseInLocation with time.Local would, into unix
|
||||||
|
/// nanoseconds.
|
||||||
|
fn parse_date(s: &str) -> Option<i64> {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
let fixed = |i: usize, n: usize| -> Option<i64> {
|
||||||
|
let d = b.get(i..i + n)?;
|
||||||
|
d.iter().all(u8::is_ascii_digit).then(|| d.iter().fold(0, |a, &c| a * 10 + (c - b'0') as i64))
|
||||||
|
};
|
||||||
|
let (y, mo, d) = (fixed(0, 4)?, fixed(5, 2)?, fixed(8, 2)?);
|
||||||
|
if b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') || !(1..=12).contains(&mo) || d < 1 || d > days_in(y, mo) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if b.len() == 10 {
|
||||||
|
return local_nanos(y, mo, d, 0, 0);
|
||||||
|
}
|
||||||
|
let sep = *b.get(10)?;
|
||||||
|
if sep != b'T' && sep != b' ' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Go's "15" hour accepts one or two digits.
|
||||||
|
let mut i = 11;
|
||||||
|
let hl = if fixed(i, 2).is_some() { 2 } else { 1 };
|
||||||
|
let h = fixed(i, hl)?;
|
||||||
|
i += hl;
|
||||||
|
if b.get(i) != Some(&b':') || h > 23 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mi = fixed(i + 1, 2)?;
|
||||||
|
i += 3;
|
||||||
|
if mi > 59 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if i == b.len() {
|
||||||
|
return local_nanos(y, mo, d, h, mi);
|
||||||
|
}
|
||||||
|
// RFC 3339: :05[.frac](Z|±07:00)
|
||||||
|
if sep != b'T' || b.get(i) != Some(&b':') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sec = fixed(i + 1, 2)?;
|
||||||
|
i += 3;
|
||||||
|
if sec > 59 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut ns = 0i64;
|
||||||
|
if matches!(b.get(i), Some(b'.' | b',')) {
|
||||||
|
let n = b[i + 1..].iter().take_while(|c| c.is_ascii_digit()).count();
|
||||||
|
if n == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for k in 0..9 {
|
||||||
|
ns = ns * 10 + b.get(i + 1 + k).filter(|_| k < n).map_or(0, |c| (c - b'0') as i64);
|
||||||
|
}
|
||||||
|
i += 1 + n;
|
||||||
|
}
|
||||||
|
let off = match b.get(i)? {
|
||||||
|
b'Z' => {
|
||||||
|
i += 1;
|
||||||
|
0
|
||||||
|
}
|
||||||
|
&c @ (b'+' | b'-') => {
|
||||||
|
let (oh, om) = (fixed(i + 1, 2)?, fixed(i + 4, 2)?);
|
||||||
|
if b.get(i + 3) != Some(&b':') || oh > 23 || om > 59 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
i += 6;
|
||||||
|
(oh * 3600 + om * 60) * if c == b'-' { -1 } else { 1 }
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
if i != b.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let secs = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + sec - off;
|
||||||
|
Some(secs * 1_000_000_000 + ns)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn days_in(y: i64, m: i64) -> i64 {
|
||||||
|
match m {
|
||||||
|
2 if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) => 29,
|
||||||
|
2 => 28,
|
||||||
|
4 | 6 | 9 | 11 => 30,
|
||||||
|
_ => 31,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days since 1970-01-01 of a proleptic Gregorian date.
|
||||||
|
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||||
|
let y = if m <= 2 { y - 1 } else { y };
|
||||||
|
let era = y.div_euclid(400);
|
||||||
|
let yoe = y - era * 400;
|
||||||
|
let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
era * 146097 + doe - 719468
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_nanos(y: i64, mo: i64, d: i64, h: i64, mi: i64) -> Option<i64> {
|
||||||
|
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
|
||||||
|
tm.tm_year = (y - 1900) as i32;
|
||||||
|
tm.tm_mon = (mo - 1) as i32;
|
||||||
|
tm.tm_mday = d as i32;
|
||||||
|
tm.tm_hour = h as i32;
|
||||||
|
tm.tm_min = mi as i32;
|
||||||
|
tm.tm_isdst = -1;
|
||||||
|
let t = unsafe { libc::mktime(&mut tm) };
|
||||||
|
(t != -1).then(|| t as i64 * 1_000_000_000)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod brand;
|
|||||||
pub mod cancel;
|
pub mod cancel;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
pub mod filter;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
|
|||||||
Reference in New Issue
Block a user