@@ -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
544735pub fn parse_ledger_str ( yaml : & str ) -> Result < Ledger > {
0 commit comments