Skip to content

Commit 37898b8

Browse files
committed
Add --tag flag to filter transfers by entity tags
Supports boolean expressions: 'person', 'person or store', '(person or user) and store', 'not company'. A transfer is included if either its from or to entity satisfies the expression. Available on all commands that support --begin/--end.
1 parent 2c875c9 commit 37898b8

7 files changed

Lines changed: 620 additions & 10 deletions

File tree

docs_src/02_Usage.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,56 @@ gplot-cumul Code and data for cumuluative gnuplot step chart
153153
```
154154

155155

156+
#### Filtering
157+
158+
Most commands support `--begin`, `--end`, `--owner`, and `--tag` flags
159+
to narrow down results.
160+
161+
**Date range** with `--begin` (inclusive) and `--end` (exclusive):
162+
163+
```shell
164+
transity balance examples/journal.yaml --begin 2024-01-01 --end 2025-01-01
165+
```
166+
167+
**Owner override** with `--owner`:
168+
169+
```shell
170+
transity balance examples/journal.yaml --owner anna
171+
```
172+
173+
**Tag filter** with `--tag`:
174+
175+
The `--tag` flag accepts a boolean expression over entity tags.
176+
A transfer is included if at least one of its entities
177+
(the `from` or `to` side) satisfies the expression.
178+
179+
```shell
180+
# Simple: include transfers involving entities tagged "person"
181+
transity balance examples/journal.yaml --tag person
182+
183+
# OR: include transfers involving "person" or "company" entities
184+
transity balance examples/journal.yaml --tag 'person or company'
185+
186+
# AND: only entities that have both tags
187+
transity balance examples/journal.yaml --tag 'person and owner'
188+
189+
# NOT: exclude entities tagged "company"
190+
transity balance examples/journal.yaml --tag 'not company'
191+
192+
# Parentheses for grouping
193+
transity balance examples/journal.yaml --tag '(person or company) and not owner'
194+
```
195+
196+
Operator precedence from highest to lowest: `not`, `and`, `or`.
197+
Filters can be combined:
198+
199+
```shell
200+
transity transfers examples/journal.yaml \
201+
--begin 2024-01-01 \
202+
--tag 'person and not company'
203+
```
204+
205+
156206
#### Transfers
157207

158208
<img

src/lib.rs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,197 @@ impl Ledger {
539539
original_account_ids: self.original_account_ids.clone(),
540540
}
541541
}
542+
543+
/// Return a new ledger keeping only transactions where at least one
544+
/// transfer involves an entity matching the tag expression.
545+
/// A transfer matches if the `from` or `to` entity's tags satisfy
546+
/// the expression.
547+
pub fn filter_by_tags(&self, expr: &TagExpr) -> Ledger {
548+
// Build map: entity_id -> set of tags
549+
let entity_tags: HashMap<&str, std::collections::HashSet<&str>> = self
550+
.entities
551+
.iter()
552+
.map(|e| {
553+
let tags: std::collections::HashSet<&str> = e
554+
.tags
555+
.as_ref()
556+
.map(|ts| ts.iter().map(|t| t.as_str()).collect())
557+
.unwrap_or_default();
558+
(e.id.as_str(), tags)
559+
})
560+
.collect();
561+
562+
let sep = &self.separator;
563+
let empty: std::collections::HashSet<&str> =
564+
std::collections::HashSet::new();
565+
566+
let transactions = self
567+
.transactions
568+
.iter()
569+
.filter_map(|tx| {
570+
let filtered_transfers: Vec<Transfer> = tx
571+
.transfers
572+
.iter()
573+
.filter(|t| {
574+
let from_entity = t
575+
.from
576+
.split_once(sep.as_str())
577+
.map_or(t.from.as_str(), |(e, _)| e);
578+
let to_entity = t
579+
.to
580+
.split_once(sep.as_str())
581+
.map_or(t.to.as_str(), |(e, _)| e);
582+
let from_tags = entity_tags.get(from_entity).unwrap_or(&empty);
583+
let to_tags = entity_tags.get(to_entity).unwrap_or(&empty);
584+
expr.eval(from_tags) || expr.eval(to_tags)
585+
})
586+
.cloned()
587+
.collect();
588+
if filtered_transfers.is_empty() {
589+
None
590+
} else {
591+
Some(Transaction {
592+
transfers: filtered_transfers,
593+
..tx.clone()
594+
})
595+
}
596+
})
597+
.collect();
598+
Ledger {
599+
owner: self.owner.clone(),
600+
separator: self.separator.clone(),
601+
separator_is_explicit: self.separator_is_explicit,
602+
entities: self.entities.clone(),
603+
transactions,
604+
original_account_ids: self.original_account_ids.clone(),
605+
}
606+
}
607+
}
608+
609+
// ─── TAG EXPRESSION ─────────────────────────────────────────────────────────
610+
611+
/// Boolean expression over entity tags.
612+
#[derive(Debug, Clone, PartialEq)]
613+
pub enum TagExpr {
614+
Tag(String),
615+
And(Box<TagExpr>, Box<TagExpr>),
616+
Or(Box<TagExpr>, Box<TagExpr>),
617+
Not(Box<TagExpr>),
618+
}
619+
620+
impl TagExpr {
621+
/// Evaluate the expression against a set of tags.
622+
pub fn eval(&self, tags: &std::collections::HashSet<&str>) -> bool {
623+
match self {
624+
TagExpr::Tag(t) => tags.contains(t.as_str()),
625+
TagExpr::And(a, b) => a.eval(tags) && b.eval(tags),
626+
TagExpr::Or(a, b) => a.eval(tags) || b.eval(tags),
627+
TagExpr::Not(a) => !a.eval(tags),
628+
}
629+
}
630+
}
631+
632+
/// Parse a tag filter expression.
633+
///
634+
/// Grammar (precedence low→high):
635+
/// expr = and_expr ('or' and_expr)*
636+
/// and = unary ('and' unary)*
637+
/// unary = 'not' unary | atom
638+
/// atom = TAG | '(' expr ')'
639+
///
640+
/// TAG is any run of non-whitespace chars that isn't a keyword or paren.
641+
pub fn parse_tag_expr(input: &str) -> Result<TagExpr> {
642+
let tokens = tokenize_tag_expr(input)?;
643+
let mut pos = 0;
644+
let result = parse_or(&tokens, &mut pos)?;
645+
if pos != tokens.len() {
646+
return Err(anyhow!(
647+
"Unexpected token '{}' at position {}",
648+
tokens[pos],
649+
pos
650+
));
651+
}
652+
Ok(result)
653+
}
654+
655+
fn tokenize_tag_expr(input: &str) -> Result<Vec<String>> {
656+
let mut tokens = Vec::new();
657+
let mut chars = input.chars().peekable();
658+
while let Some(&c) = chars.peek() {
659+
if c.is_whitespace() {
660+
chars.next();
661+
continue;
662+
}
663+
if c == '(' || c == ')' {
664+
tokens.push(c.to_string());
665+
chars.next();
666+
continue;
667+
}
668+
// Collect a word
669+
let mut word = String::new();
670+
while let Some(&c) = chars.peek() {
671+
if c.is_whitespace() || c == '(' || c == ')' {
672+
break;
673+
}
674+
word.push(c);
675+
chars.next();
676+
}
677+
tokens.push(word);
678+
}
679+
if tokens.is_empty() {
680+
return Err(anyhow!("Empty tag expression"));
681+
}
682+
Ok(tokens)
683+
}
684+
685+
fn parse_or(tokens: &[String], pos: &mut usize) -> Result<TagExpr> {
686+
let mut left = parse_and(tokens, pos)?;
687+
while *pos < tokens.len() && tokens[*pos] == "or" {
688+
*pos += 1;
689+
let right = parse_and(tokens, pos)?;
690+
left = TagExpr::Or(Box::new(left), Box::new(right));
691+
}
692+
Ok(left)
693+
}
694+
695+
fn parse_and(tokens: &[String], pos: &mut usize) -> Result<TagExpr> {
696+
let mut left = parse_unary(tokens, pos)?;
697+
while *pos < tokens.len() && tokens[*pos] == "and" {
698+
*pos += 1;
699+
let right = parse_unary(tokens, pos)?;
700+
left = TagExpr::And(Box::new(left), Box::new(right));
701+
}
702+
Ok(left)
703+
}
704+
705+
fn parse_unary(tokens: &[String], pos: &mut usize) -> Result<TagExpr> {
706+
if *pos < tokens.len() && tokens[*pos] == "not" {
707+
*pos += 1;
708+
let inner = parse_unary(tokens, pos)?;
709+
return Ok(TagExpr::Not(Box::new(inner)));
710+
}
711+
parse_atom(tokens, pos)
712+
}
713+
714+
fn parse_atom(tokens: &[String], pos: &mut usize) -> Result<TagExpr> {
715+
if *pos >= tokens.len() {
716+
return Err(anyhow!("Unexpected end of tag expression"));
717+
}
718+
if tokens[*pos] == "(" {
719+
*pos += 1;
720+
let inner = parse_or(tokens, pos)?;
721+
if *pos >= tokens.len() || tokens[*pos] != ")" {
722+
return Err(anyhow!("Missing closing parenthesis in tag expression"));
723+
}
724+
*pos += 1;
725+
return Ok(inner);
726+
}
727+
let tok = &tokens[*pos];
728+
if tok == ")" || tok == "and" || tok == "or" {
729+
return Err(anyhow!("Unexpected '{}' in tag expression", tok));
730+
}
731+
*pos += 1;
732+
Ok(TagExpr::Tag(tok.clone()))
542733
}
543734

544735
pub fn parse_ledger_str(yaml: &str) -> Result<Ledger> {

0 commit comments

Comments
 (0)