use clap::Args;
use liwe::model::Key;
use liwe::query::{parse_filter_expression, Filter, InclusionAnchor, KeyOp, ReferenceAnchor};
const LEGACY_ALIAS_DEPTH: u32 = 1;
#[derive(Debug, Clone)]
pub struct KeyDepth {
pub key: Key,
pub depth: Option<u8>,
}
impl KeyDepth {
pub fn bare(key: Key) -> Self {
Self { key, depth: None }
}
pub fn with_depth(key: Key, depth: u8) -> Self {
Self {
key,
depth: Some(depth),
}
}
fn inclusion_anchor(&self, default_depth: Option<u8>) -> InclusionAnchor {
let max = resolve_depth(self.depth, default_depth);
InclusionAnchor::with_max(self.key.to_string(), max)
}
fn reference_anchor(&self, default_depth: Option<u8>) -> ReferenceAnchor {
let max = resolve_depth(self.depth, default_depth);
ReferenceAnchor::with_max(self.key.to_string(), max)
}
}
fn resolve_depth(explicit: Option<u8>, default: Option<u8>) -> u32 {
let raw = explicit.or(default).unwrap_or(1);
if raw == 0 {
u32::MAX
} else {
u32::from(raw)
}
}
#[derive(Debug, Args, Clone, Default)]
pub struct FilterArgs {
#[clap(
long,
help = "Filter expression. Inline YAML; wrapped in `{}` and parsed as a filter document. Example: --filter 'status: pending'."
)]
pub filter: Option<String>,
#[clap(
long,
short = 'k',
help = "Match by document key. Repeatable: 1 key uses $eq, 2+ uses $in."
)]
pub key: Vec<String>,
#[clap(
long,
value_parser = parse_key_depth,
help = "$includes anchor. KEY or KEY:DEPTH (DEPTH defaults to --max-depth). Lowers to scalar shorthand when DEPTH=1, full form { match: { $key: KEY }, maxDepth: N } otherwise. Repeatable; anchors are ANDed."
)]
pub includes: Vec<KeyDepth>,
#[clap(
long = "included-by",
value_parser = parse_key_depth,
help = "$includedBy anchor. KEY or KEY:DEPTH (DEPTH defaults to --max-depth). Lowers to scalar shorthand when DEPTH=1, full form { match: { $key: KEY }, maxDepth: N } otherwise. Repeatable; anchors are ANDed."
)]
pub included_by: Vec<KeyDepth>,
#[clap(
long,
value_parser = parse_key_depth,
help = "$references anchor. KEY or KEY:DIST (DIST defaults to --max-distance). Lowers to scalar shorthand when DIST=1, full form { match: { $key: KEY }, maxDistance: N } otherwise. Repeatable; anchors are ANDed."
)]
pub references: Vec<KeyDepth>,
#[clap(
long = "referenced-by",
value_parser = parse_key_depth,
help = "$referencedBy anchor. KEY or KEY:DIST (DIST defaults to --max-distance). Lowers to scalar shorthand when DIST=1, full form { match: { $key: KEY }, maxDistance: N } otherwise. Repeatable; anchors are ANDed."
)]
pub referenced_by: Vec<KeyDepth>,
#[clap(
long = "in",
hide = true,
value_parser = parse_key_depth,
)]
pub in_: Vec<KeyDepth>,
#[clap(long = "in-any", hide = true)]
pub in_any: Vec<String>,
#[clap(long = "not-in", hide = true)]
pub not_in: Vec<String>,
#[clap(long = "refs-to", hide = true)]
pub refs_to: Option<String>,
#[clap(long = "refs-from", hide = true)]
pub refs_from: Option<String>,
#[clap(
long,
help = "Only match root documents (those with no incoming inclusion edges)."
)]
pub roots: bool,
#[clap(
long = "max-depth",
help = "Default maxDepth applied to inclusion anchor flags without a colon-suffix. Default 1."
)]
pub max_depth: Option<u8>,
#[clap(
long = "max-distance",
help = "Default maxDistance applied to reference anchor flags without a colon-suffix. Default 1."
)]
pub max_distance: Option<u8>,
}
impl FilterArgs {
pub fn has_non_key_clauses(&self) -> bool {
self.filter.is_some()
|| self.roots
|| !self.includes.is_empty()
|| !self.included_by.is_empty()
|| !self.references.is_empty()
|| !self.referenced_by.is_empty()
|| !self.in_.is_empty()
|| !self.in_any.is_empty()
|| !self.not_in.is_empty()
|| self.refs_to.is_some()
|| self.refs_from.is_some()
|| self.max_depth.is_some()
|| self.max_distance.is_some()
}
pub fn is_empty(&self) -> bool {
self.filter.is_none()
&& self.key.is_empty()
&& !self.roots
&& self.includes.is_empty()
&& self.included_by.is_empty()
&& self.references.is_empty()
&& self.referenced_by.is_empty()
&& self.in_.is_empty()
&& self.in_any.is_empty()
&& self.not_in.is_empty()
&& self.refs_to.is_none()
&& self.refs_from.is_none()
&& self.max_depth.is_none()
&& self.max_distance.is_none()
}
pub fn to_filter(&self) -> Result<Option<Filter>, String> {
if self.is_empty() {
return Ok(None);
}
let mut conjuncts: Vec<Filter> = Vec::new();
if let Some(expr) = &self.filter {
let parsed = parse_filter_expression(expr)
.map_err(|e| format!("invalid --filter expression: {}", e))?;
if !self.key.is_empty() && filter_has_top_level_key(&parsed) {
return Err(
"-k / --key conflicts with a $key predicate at the top level of --filter; \
use --filter '$or: [{$key: a}, {$key: b}]' for OR-of-keys, or pick one source"
.to_string(),
);
}
conjuncts.push(parsed);
}
match self.key.len() {
0 => {}
1 => conjuncts.push(Filter::Key(KeyOp::Eq(Key::name(&self.key[0])))),
_ => conjuncts.push(Filter::Key(KeyOp::In(
self.key.iter().map(|s| Key::name(s)).collect(),
))),
}
for kd in &self.includes {
conjuncts.push(Filter::Includes(Box::new(
kd.inclusion_anchor(self.max_depth),
)));
}
for kd in &self.included_by {
conjuncts.push(Filter::IncludedBy(Box::new(
kd.inclusion_anchor(self.max_depth),
)));
}
for kd in &self.references {
conjuncts.push(Filter::References(Box::new(
kd.reference_anchor(self.max_distance),
)));
}
for kd in &self.referenced_by {
conjuncts.push(Filter::ReferencedBy(Box::new(
kd.reference_anchor(self.max_distance),
)));
}
for kd in &self.in_ {
eprintln!("warning: --in is deprecated; use --included-by");
conjuncts.push(Filter::IncludedBy(Box::new(
kd.inclusion_anchor(self.max_depth),
)));
}
if !self.in_any.is_empty() {
eprintln!(
"warning: --in-any is deprecated; use --filter '$or: [{{ $includedBy: K1 }}, {{ $includedBy: K2 }}]'"
);
conjuncts.push(Filter::Or(
self.in_any
.iter()
.map(|k| {
Filter::IncludedBy(Box::new(InclusionAnchor::with_max(
k,
LEGACY_ALIAS_DEPTH,
)))
})
.collect(),
));
}
for k in &self.not_in {
eprintln!(
"warning: --not-in is deprecated; use --filter '$nor: [{{ $includedBy: ... }}]'"
);
conjuncts.push(Filter::Nor(vec![Filter::IncludedBy(Box::new(
InclusionAnchor::with_max(k, LEGACY_ALIAS_DEPTH),
))]));
}
if let Some(k) = &self.refs_to {
eprintln!("warning: --refs-to is deprecated; use --references");
conjuncts.push(Filter::Or(vec![
Filter::Includes(Box::new(InclusionAnchor::with_max(k, LEGACY_ALIAS_DEPTH))),
Filter::References(Box::new(ReferenceAnchor::with_max(k, LEGACY_ALIAS_DEPTH))),
]));
}
if let Some(k) = &self.refs_from {
eprintln!("warning: --refs-from is deprecated; use --referenced-by");
conjuncts.push(Filter::Or(vec![
Filter::IncludedBy(Box::new(InclusionAnchor::with_max(k, LEGACY_ALIAS_DEPTH))),
Filter::ReferencedBy(Box::new(ReferenceAnchor::with_max(k, LEGACY_ALIAS_DEPTH))),
]));
}
if conjuncts.is_empty() {
return Ok(None);
}
if conjuncts.len() == 1 {
return Ok(Some(conjuncts.into_iter().next().unwrap()));
}
Ok(Some(Filter::And(conjuncts)))
}
}
fn filter_has_top_level_key(filter: &Filter) -> bool {
match filter {
Filter::Key(_) => true,
Filter::And(children) => children.iter().any(filter_has_top_level_key),
_ => false,
}
}
fn parse_key_depth(s: &str) -> Result<KeyDepth, String> {
if let Some((k, d)) = s.rsplit_once(':') {
let depth: u8 = d
.parse()
.map_err(|_| format!("invalid depth in '{}': depth must be 0..=255", s))?;
Ok(KeyDepth::with_depth(Key::name(k), depth))
} else {
Ok(KeyDepth::bare(Key::name(s)))
}
}