Skip to main content

provenant/license_detection/expression/
simplify.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! License expression simplification and utilities.
5
6use std::collections::HashSet;
7
8use super::{LicenseExpression, ParseError};
9
10/// Simplify a license expression by deduplicating and reducing boolean clauses.
11///
12/// # Arguments
13/// * `expr` - The expression to simplify
14///
15/// # Returns
16/// Simplified expression with duplicate and subsumed licenses removed,
17/// using deterministic canonical ordering for boolean operand chains.
18pub fn simplify_expression(expr: &LicenseExpression) -> LicenseExpression {
19    canonicalize_expression(expr, true)
20}
21
22/// Canonicalize a license expression while preserving non-identical operands.
23///
24/// This still flattens boolean chains, removes exact duplicates, and sorts
25/// operands deterministically, but it deliberately avoids absorption such as
26/// `MIT AND (Apache-2.0 OR MIT) -> MIT`.
27pub fn simplify_expression_preserving_structure(expr: &LicenseExpression) -> LicenseExpression {
28    canonicalize_expression(expr, false)
29}
30
31fn canonicalize_expression(
32    expr: &LicenseExpression,
33    prune_subsumed_operands_enabled: bool,
34) -> LicenseExpression {
35    match expr {
36        LicenseExpression::License(key) => LicenseExpression::License(key.clone()),
37        LicenseExpression::LicenseRef(key) => LicenseExpression::LicenseRef(key.clone()),
38        LicenseExpression::With { left, right } => LicenseExpression::With {
39            left: Box::new(canonicalize_expression(
40                left,
41                prune_subsumed_operands_enabled,
42            )),
43            right: Box::new(canonicalize_expression(
44                right,
45                prune_subsumed_operands_enabled,
46            )),
47        },
48        LicenseExpression::And { .. } => {
49            let mut unique = Vec::new();
50            let mut seen = HashSet::new();
51            collect_unique_and(
52                expr,
53                &mut unique,
54                &mut seen,
55                prune_subsumed_operands_enabled,
56            );
57            if prune_subsumed_operands_enabled {
58                prune_subsumed_operands(&mut unique, true);
59                sort_operands_canonically(&mut unique);
60            }
61            build_expression_from_list(&unique, true)
62        }
63        LicenseExpression::Or { .. } => {
64            let mut unique = Vec::new();
65            let mut seen = HashSet::new();
66            collect_unique_or(
67                expr,
68                &mut unique,
69                &mut seen,
70                prune_subsumed_operands_enabled,
71            );
72            if prune_subsumed_operands_enabled {
73                prune_subsumed_operands(&mut unique, false);
74                sort_operands_canonically(&mut unique);
75            }
76            build_expression_from_list(&unique, false)
77        }
78    }
79}
80
81fn sort_operands_canonically(operands: &mut [LicenseExpression]) {
82    operands.sort_by_cached_key(canonical_operand_sort_key);
83}
84
85fn canonical_operand_sort_key(expr: &LicenseExpression) -> String {
86    expression_to_string(expr).to_ascii_lowercase()
87}
88
89fn prune_subsumed_operands(operands: &mut Vec<LicenseExpression>, outer_is_and: bool) {
90    let inner_is_and = !outer_is_and;
91    let pruned: Vec<LicenseExpression> = operands
92        .iter()
93        .enumerate()
94        .filter(|(candidate_idx, candidate)| {
95            !operands.iter().enumerate().any(|(other_idx, other)| {
96                candidate_idx != &other_idx && operand_subsumes(other, candidate, inner_is_and)
97            })
98        })
99        .map(|(_, operand)| operand.clone())
100        .collect();
101
102    *operands = pruned;
103}
104
105fn operand_subsumes(
106    other: &LicenseExpression,
107    candidate: &LicenseExpression,
108    inner_is_and: bool,
109) -> bool {
110    let other_args = get_flat_args(other);
111    let candidate_args = get_flat_args(candidate);
112
113    if other_args.len() >= candidate_args.len() {
114        return false;
115    }
116
117    let relevant_operator = matches!(other, LicenseExpression::And { .. })
118        || matches!(other, LicenseExpression::Or { .. })
119        || matches!(candidate, LicenseExpression::And { .. })
120        || matches!(candidate, LicenseExpression::Or { .. });
121
122    if !relevant_operator {
123        return false;
124    }
125
126    let operator_matches = if inner_is_and {
127        matches!(candidate, LicenseExpression::And { .. })
128            || matches!(other, LicenseExpression::And { .. })
129    } else {
130        matches!(candidate, LicenseExpression::Or { .. })
131            || matches!(other, LicenseExpression::Or { .. })
132    };
133
134    if !operator_matches {
135        return false;
136    }
137
138    other_args.iter().all(|other_arg| {
139        candidate_args
140            .iter()
141            .any(|arg| expressions_equal(arg, other_arg))
142    })
143}
144
145fn collect_unique_and(
146    expr: &LicenseExpression,
147    unique: &mut Vec<LicenseExpression>,
148    seen: &mut HashSet<String>,
149    prune_subsumed_operands_enabled: bool,
150) {
151    match expr {
152        LicenseExpression::And { left, right } => {
153            collect_unique_and(left, unique, seen, prune_subsumed_operands_enabled);
154            collect_unique_and(right, unique, seen, prune_subsumed_operands_enabled);
155        }
156        LicenseExpression::Or { .. } => {
157            let simplified = canonicalize_expression(expr, prune_subsumed_operands_enabled);
158            let key = expression_to_string(&simplified);
159            if !seen.contains(&key) {
160                seen.insert(key);
161                unique.push(simplified);
162            }
163        }
164        LicenseExpression::With { left, right } => {
165            let simplified = LicenseExpression::With {
166                left: Box::new(canonicalize_expression(
167                    left,
168                    prune_subsumed_operands_enabled,
169                )),
170                right: Box::new(canonicalize_expression(
171                    right,
172                    prune_subsumed_operands_enabled,
173                )),
174            };
175            let key = expression_to_string(&simplified);
176            if !seen.contains(&key) {
177                seen.insert(key);
178                unique.push(simplified);
179            }
180        }
181        LicenseExpression::License(key) => {
182            if !seen.contains(key) {
183                seen.insert(key.clone());
184                unique.push(LicenseExpression::License(key.clone()));
185            }
186        }
187        LicenseExpression::LicenseRef(key) => {
188            if !seen.contains(key) {
189                seen.insert(key.clone());
190                unique.push(LicenseExpression::LicenseRef(key.clone()));
191            }
192        }
193    }
194}
195
196fn collect_unique_or(
197    expr: &LicenseExpression,
198    unique: &mut Vec<LicenseExpression>,
199    seen: &mut HashSet<String>,
200    prune_subsumed_operands_enabled: bool,
201) {
202    match expr {
203        LicenseExpression::Or { left, right } => {
204            collect_unique_or(left, unique, seen, prune_subsumed_operands_enabled);
205            collect_unique_or(right, unique, seen, prune_subsumed_operands_enabled);
206        }
207        LicenseExpression::And { .. } => {
208            let simplified = canonicalize_expression(expr, prune_subsumed_operands_enabled);
209            let key = expression_to_string(&simplified);
210            if !seen.contains(&key) {
211                seen.insert(key);
212                unique.push(simplified);
213            }
214        }
215        LicenseExpression::With { left, right } => {
216            let simplified = LicenseExpression::With {
217                left: Box::new(canonicalize_expression(
218                    left,
219                    prune_subsumed_operands_enabled,
220                )),
221                right: Box::new(canonicalize_expression(
222                    right,
223                    prune_subsumed_operands_enabled,
224                )),
225            };
226            let key = expression_to_string(&simplified);
227            if !seen.contains(&key) {
228                seen.insert(key);
229                unique.push(simplified);
230            }
231        }
232        LicenseExpression::License(key) => {
233            if !seen.contains(key) {
234                seen.insert(key.clone());
235                unique.push(LicenseExpression::License(key.clone()));
236            }
237        }
238        LicenseExpression::LicenseRef(key) => {
239            if !seen.contains(key) {
240                seen.insert(key.clone());
241                unique.push(LicenseExpression::LicenseRef(key.clone()));
242            }
243        }
244    }
245}
246
247fn build_expression_from_list(unique: &[LicenseExpression], is_and: bool) -> LicenseExpression {
248    debug_assert!(
249        !unique.is_empty(),
250        "build_expression_from_list called with empty list"
251    );
252    match unique.len() {
253        1 => unique[0].clone(),
254        _ => {
255            let midpoint = unique.len() / 2;
256            let left = build_expression_from_list(&unique[..midpoint], is_and);
257            let right = build_expression_from_list(&unique[midpoint..], is_and);
258            if is_and {
259                LicenseExpression::And {
260                    left: Box::new(left),
261                    right: Box::new(right),
262                }
263            } else {
264                LicenseExpression::Or {
265                    left: Box::new(left),
266                    right: Box::new(right),
267                }
268            }
269        }
270    }
271}
272
273fn get_flat_args(expr: &LicenseExpression) -> Vec<LicenseExpression> {
274    match expr {
275        LicenseExpression::And { left, right } => {
276            let mut args = Vec::new();
277            collect_flat_and_args(left, &mut args);
278            collect_flat_and_args(right, &mut args);
279            args
280        }
281        LicenseExpression::Or { left, right } => {
282            let mut args = Vec::new();
283            collect_flat_or_args(left, &mut args);
284            collect_flat_or_args(right, &mut args);
285            args
286        }
287        _ => vec![expr.clone()],
288    }
289}
290
291fn collect_flat_and_args(expr: &LicenseExpression, args: &mut Vec<LicenseExpression>) {
292    match expr {
293        LicenseExpression::And { left, right } => {
294            collect_flat_and_args(left, args);
295            collect_flat_and_args(right, args);
296        }
297        _ => args.push(expr.clone()),
298    }
299}
300
301fn collect_flat_or_args(expr: &LicenseExpression, args: &mut Vec<LicenseExpression>) {
302    match expr {
303        LicenseExpression::Or { left, right } => {
304            collect_flat_or_args(left, args);
305            collect_flat_or_args(right, args);
306        }
307        _ => args.push(expr.clone()),
308    }
309}
310
311fn decompose_expr(expr: &LicenseExpression) -> Vec<LicenseExpression> {
312    match expr {
313        LicenseExpression::With { left, right } => {
314            let mut parts = decompose_expr(left);
315            parts.extend(decompose_expr(right));
316            parts
317        }
318        _ => vec![expr.clone()],
319    }
320}
321
322fn expressions_equal(a: &LicenseExpression, b: &LicenseExpression) -> bool {
323    match (a, b) {
324        (LicenseExpression::License(ka), LicenseExpression::License(kb)) => ka == kb,
325        (LicenseExpression::LicenseRef(ka), LicenseExpression::LicenseRef(kb)) => ka == kb,
326        (
327            LicenseExpression::With {
328                left: l1,
329                right: r1,
330            },
331            LicenseExpression::With {
332                left: l2,
333                right: r2,
334            },
335        ) => expressions_equal(l1, l2) && expressions_equal(r1, r2),
336        (LicenseExpression::And { .. }, LicenseExpression::And { .. }) => {
337            let args_a = get_flat_args(a);
338            let args_b = get_flat_args(b);
339            args_a.len() == args_b.len()
340                && args_b
341                    .iter()
342                    .all(|b_arg| args_a.iter().any(|a_arg| expressions_equal(a_arg, b_arg)))
343        }
344        (LicenseExpression::Or { .. }, LicenseExpression::Or { .. }) => {
345            let args_a = get_flat_args(a);
346            let args_b = get_flat_args(b);
347            args_a.len() == args_b.len()
348                && args_b
349                    .iter()
350                    .all(|b_arg| args_a.iter().any(|a_arg| expressions_equal(a_arg, b_arg)))
351        }
352        _ => false,
353    }
354}
355
356fn expr_in_args(expr: &LicenseExpression, args: &[LicenseExpression]) -> bool {
357    if args.iter().any(|a| expressions_equal(a, expr)) {
358        return true;
359    }
360    let decomposed = decompose_expr(expr);
361    if decomposed.len() == 1 {
362        return false;
363    }
364    decomposed
365        .iter()
366        .any(|d| args.iter().any(|a| expressions_equal(a, d)))
367}
368
369pub fn licensing_contains(container: &str, contained: &str) -> bool {
370    let container = container.trim();
371    let contained = contained.trim();
372    if container.is_empty() || contained.is_empty() {
373        return false;
374    }
375
376    if container == contained {
377        return true;
378    }
379
380    let Ok(parsed_container) = super::parse::parse_expression(container) else {
381        return false;
382    };
383    let Ok(parsed_contained) = super::parse::parse_expression(contained) else {
384        return false;
385    };
386
387    let simplified_container = simplify_expression(&parsed_container);
388    let simplified_contained = simplify_expression(&parsed_contained);
389
390    match (&simplified_container, &simplified_contained) {
391        (LicenseExpression::And { .. }, LicenseExpression::And { .. })
392        | (LicenseExpression::Or { .. }, LicenseExpression::Or { .. }) => {
393            let container_args = get_flat_args(&simplified_container);
394            let contained_args = get_flat_args(&simplified_contained);
395            contained_args
396                .iter()
397                .all(|c| container_args.iter().any(|ca| expressions_equal(ca, c)))
398        }
399        (
400            LicenseExpression::And { .. } | LicenseExpression::Or { .. },
401            LicenseExpression::License(_) | LicenseExpression::LicenseRef(_),
402        ) => {
403            let container_args = get_flat_args(&simplified_container);
404            expr_in_args(&simplified_contained, &container_args)
405        }
406        (LicenseExpression::And { .. } | LicenseExpression::Or { .. }, _) => {
407            let container_args = get_flat_args(&simplified_container);
408            container_args
409                .iter()
410                .any(|ca| expressions_equal(ca, &simplified_contained))
411        }
412        (
413            LicenseExpression::With { .. },
414            LicenseExpression::License(_) | LicenseExpression::LicenseRef(_),
415        ) => {
416            let decomposed = decompose_expr(&simplified_container);
417            decomposed
418                .iter()
419                .any(|d| expressions_equal(d, &simplified_contained))
420        }
421        (
422            LicenseExpression::License(_) | LicenseExpression::LicenseRef(_),
423            LicenseExpression::And { .. }
424            | LicenseExpression::Or { .. }
425            | LicenseExpression::With { .. },
426        ) => false,
427        (LicenseExpression::License(k1), LicenseExpression::License(k2)) => k1 == k2,
428        (LicenseExpression::LicenseRef(k1), LicenseExpression::LicenseRef(k2)) => k1 == k2,
429        _ => false,
430    }
431}
432
433/// # Returns
434/// String representation of the expression
435///
436/// # Parentheses
437/// Parentheses are added when needed to preserve semantic meaning based on
438/// operator precedence (WITH > AND > OR). This matches the Python
439/// license-expression library behavior.
440/// Convert a license expression to its string representation.
441#[derive(Clone, Copy)]
442enum BooleanOperator {
443    And,
444    Or,
445}
446
447pub fn expression_to_string(expr: &LicenseExpression) -> String {
448    match expr {
449        LicenseExpression::License(key) => key.clone(),
450        LicenseExpression::LicenseRef(key) => key.clone(),
451        LicenseExpression::And { .. } => render_flat_boolean_chain(expr, BooleanOperator::And),
452        LicenseExpression::Or { .. } => render_flat_boolean_chain(expr, BooleanOperator::Or),
453        LicenseExpression::With { left, right } => {
454            let left_str = expression_to_string(left);
455            let right_str = expression_to_string(right);
456            format!("{} WITH {}", left_str, right_str)
457        }
458    }
459}
460
461fn render_flat_boolean_chain(expr: &LicenseExpression, operator: BooleanOperator) -> String {
462    let mut parts = Vec::new();
463    collect_boolean_chain(expr, operator, &mut parts);
464
465    let separator = match operator {
466        BooleanOperator::And => " AND ",
467        BooleanOperator::Or => " OR ",
468    };
469
470    parts
471        .into_iter()
472        .map(|part| render_boolean_operand(part, operator))
473        .collect::<Vec<_>>()
474        .join(separator)
475}
476
477fn collect_boolean_chain<'a>(
478    expr: &'a LicenseExpression,
479    operator: BooleanOperator,
480    parts: &mut Vec<&'a LicenseExpression>,
481) {
482    match (operator, expr) {
483        (BooleanOperator::And, LicenseExpression::And { left, right })
484        | (BooleanOperator::Or, LicenseExpression::Or { left, right }) => {
485            collect_boolean_chain(left, operator, parts);
486            collect_boolean_chain(right, operator, parts);
487        }
488        _ => parts.push(expr),
489    }
490}
491
492fn render_boolean_operand(expr: &LicenseExpression, parent_operator: BooleanOperator) -> String {
493    match expr {
494        LicenseExpression::License(key) => key.clone(),
495        LicenseExpression::LicenseRef(key) => key.clone(),
496        LicenseExpression::And { .. } => match parent_operator {
497            BooleanOperator::And => expression_to_string(expr),
498            BooleanOperator::Or => format!("({})", expression_to_string(expr)),
499        },
500        LicenseExpression::Or { .. } => match parent_operator {
501            BooleanOperator::Or => expression_to_string(expr),
502            BooleanOperator::And => format!("({})", expression_to_string(expr)),
503        },
504        LicenseExpression::With { left, right } => {
505            let left_str = expression_to_string(left);
506            let right_str = expression_to_string(right);
507            format!("{} WITH {}", left_str, right_str)
508        }
509    }
510}
511
512fn combine_expressions_with(
513    expressions: &[&str],
514    unique: bool,
515    combiner: fn(Vec<LicenseExpression>) -> Option<LicenseExpression>,
516    simplifier: fn(&LicenseExpression) -> LicenseExpression,
517) -> Result<String, ParseError> {
518    if expressions.is_empty() {
519        return Ok(String::new());
520    }
521    if expressions.len() == 1 {
522        let parsed = super::parse::parse_expression(expressions[0])?;
523        return Ok(expression_to_string(&if unique {
524            simplifier(&parsed)
525        } else {
526            parsed
527        }));
528    }
529
530    let parsed_exprs: Vec<LicenseExpression> = expressions
531        .iter()
532        .map(|e| super::parse::parse_expression(e))
533        .collect::<Result<Vec<_>, _>>()?;
534
535    let combined = combiner(parsed_exprs);
536
537    match combined {
538        Some(expr) => {
539            let final_expr = if unique { simplifier(&expr) } else { expr };
540            Ok(expression_to_string(&final_expr))
541        }
542        None => Ok(String::new()),
543    }
544}
545
546/// Combine multiple license expressions with `AND`.
547///
548/// This function parses each expression string, combines them with `AND`, and
549/// optionally deduplicates license keys.
550pub fn combine_expressions_and(expressions: &[&str], unique: bool) -> Result<String, ParseError> {
551    combine_expressions_with(
552        expressions,
553        unique,
554        LicenseExpression::and,
555        simplify_expression,
556    )
557}
558
559/// Combine multiple license expressions with `AND` while preserving the
560/// original boolean structure of distinct operands.
561pub fn combine_expressions_and_preserving_structure(
562    expressions: &[&str],
563    unique: bool,
564) -> Result<String, ParseError> {
565    combine_expressions_with(
566        expressions,
567        unique,
568        LicenseExpression::and,
569        simplify_expression_preserving_structure,
570    )
571}
572
573/// Combine multiple license expressions with `OR`.
574///
575/// This function parses each expression string, combines them with `OR`, and
576/// optionally deduplicates license keys.
577pub fn combine_expressions_or(expressions: &[&str], unique: bool) -> Result<String, ParseError> {
578    combine_expressions_with(
579        expressions,
580        unique,
581        LicenseExpression::or,
582        simplify_expression,
583    )
584}
585
586/// Combine multiple license expressions with `OR` while preserving the
587/// original boolean structure of distinct operands.
588pub fn combine_expressions_or_preserving_structure(
589    expressions: &[&str],
590    unique: bool,
591) -> Result<String, ParseError> {
592    combine_expressions_with(
593        expressions,
594        unique,
595        LicenseExpression::or,
596        simplify_expression_preserving_structure,
597    )
598}
599
600#[cfg(test)]
601mod tests {
602    use super::*;
603
604    fn expression_depth(expr: &LicenseExpression) -> usize {
605        match expr {
606            LicenseExpression::License(_) | LicenseExpression::LicenseRef(_) => 1,
607            LicenseExpression::And { left, right }
608            | LicenseExpression::Or { left, right }
609            | LicenseExpression::With { left, right } => {
610                1 + expression_depth(left).max(expression_depth(right))
611            }
612        }
613    }
614
615    #[test]
616    fn test_simplify_expression_no_change() {
617        let expr = super::super::parse::parse_expression("MIT AND Apache-2.0").unwrap();
618        let simplified = simplify_expression(&expr);
619        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
620    }
621
622    #[test]
623    fn test_simplify_expression_with_duplicates() {
624        let expr = super::super::parse::parse_expression("MIT OR MIT").unwrap();
625        let simplified = simplify_expression(&expr);
626        assert_eq!(expression_to_string(&simplified), "mit");
627    }
628
629    #[test]
630    fn test_simplify_expression_preserving_structure_keeps_distinct_nested_operands() {
631        let expr = super::super::parse::parse_expression("mit AND (apache-2.0 OR mit)").unwrap();
632        let simplified = simplify_expression_preserving_structure(&expr);
633        assert_eq!(
634            expression_to_string(&simplified),
635            "mit AND (apache-2.0 OR mit)"
636        );
637    }
638
639    #[test]
640    fn test_simplify_and_duplicates() {
641        let expr = super::super::parse::parse_expression("crapl-0.1 AND crapl-0.1").unwrap();
642        let simplified = simplify_expression(&expr);
643        assert_eq!(expression_to_string(&simplified), "crapl-0.1");
644    }
645
646    #[test]
647    fn test_simplify_or_duplicates() {
648        let expr = super::super::parse::parse_expression("mit OR mit").unwrap();
649        let simplified = simplify_expression(&expr);
650        assert_eq!(expression_to_string(&simplified), "mit");
651    }
652
653    #[test]
654    fn test_combine_expressions_and_preserving_structure_keeps_distinct_nested_operands() {
655        let result =
656            combine_expressions_and_preserving_structure(&["mit", "apache-2.0 OR mit"], true)
657                .unwrap();
658        assert_eq!(result, "mit AND (apache-2.0 OR mit)");
659    }
660
661    #[test]
662    fn test_simplify_preserves_different_licenses() {
663        let expr = super::super::parse::parse_expression("mit AND apache-2.0").unwrap();
664        let simplified = simplify_expression(&expr);
665        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
666    }
667
668    #[test]
669    fn test_simplify_complex_duplicates() {
670        let expr = super::super::parse::parse_expression(
671            "gpl-2.0-plus AND gpl-2.0-plus AND lgpl-2.0-plus",
672        )
673        .unwrap();
674        let simplified = simplify_expression(&expr);
675        assert_eq!(
676            expression_to_string(&simplified),
677            "gpl-2.0-plus AND lgpl-2.0-plus"
678        );
679    }
680
681    #[test]
682    fn test_simplify_three_duplicates() {
683        let expr =
684            super::super::parse::parse_expression("fsf-free AND fsf-free AND fsf-free").unwrap();
685        let simplified = simplify_expression(&expr);
686        assert_eq!(expression_to_string(&simplified), "fsf-free");
687    }
688
689    #[test]
690    fn test_simplify_with_expression_dedup() {
691        let expr = super::super::parse::parse_expression(
692            "gpl-2.0 WITH classpath-exception-2.0 AND gpl-2.0 WITH classpath-exception-2.0",
693        )
694        .unwrap();
695        let simplified = simplify_expression(&expr);
696        assert_eq!(
697            expression_to_string(&simplified),
698            "gpl-2.0 WITH classpath-exception-2.0"
699        );
700    }
701
702    #[test]
703    fn test_simplify_nested_duplicates() {
704        let expr =
705            super::super::parse::parse_expression("(mit AND apache-2.0) OR (mit AND apache-2.0)")
706                .unwrap();
707        let simplified = simplify_expression(&expr);
708        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
709    }
710
711    #[test]
712    fn test_simplify_sorts_operands_canonically() {
713        let expr =
714            super::super::parse::parse_expression("apache-2.0 AND mit AND apache-2.0").unwrap();
715        let simplified = simplify_expression(&expr);
716        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
717    }
718
719    #[test]
720    fn test_simplify_mit_and_mit_and_apache() {
721        let expr = super::super::parse::parse_expression("mit AND mit AND apache-2.0").unwrap();
722        let simplified = simplify_expression(&expr);
723        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
724    }
725
726    #[test]
727    fn test_simplify_and_absorption() {
728        let expr = super::super::parse::parse_expression("mit AND (mit OR apache-2.0)").unwrap();
729        let simplified = simplify_expression(&expr);
730
731        assert_eq!(expression_to_string(&simplified), "mit");
732    }
733
734    #[test]
735    fn test_simplify_or_absorption() {
736        let expr = super::super::parse::parse_expression("mit OR (mit AND apache-2.0)").unwrap();
737        let simplified = simplify_expression(&expr);
738
739        assert_eq!(expression_to_string(&simplified), "mit");
740    }
741
742    #[test]
743    fn test_simplify_or_subsumption() {
744        let expr = super::super::parse::parse_expression(
745            "(mit AND apache-2.0) OR (mit AND apache-2.0 AND bsd-new)",
746        )
747        .unwrap();
748        let simplified = simplify_expression(&expr);
749
750        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
751    }
752
753    #[test]
754    fn test_simplify_and_subsumption() {
755        let expr = super::super::parse::parse_expression(
756            "(mit OR apache-2.0) AND (mit OR apache-2.0 OR bsd-new)",
757        )
758        .unwrap();
759        let simplified = simplify_expression(&expr);
760
761        assert_eq!(expression_to_string(&simplified), "apache-2.0 OR mit");
762    }
763
764    #[test]
765    fn test_simplify_and_keeps_gpl_or_later_with_only() {
766        let expr =
767            super::super::parse::parse_expression("gpl-2.0-or-later AND gpl-2.0-only").unwrap();
768        let simplified = simplify_expression(&expr);
769
770        assert_eq!(
771            expression_to_string(&simplified),
772            "gpl-2.0-only AND gpl-2.0-or-later"
773        );
774    }
775
776    #[test]
777    fn test_expression_to_string_simple() {
778        let expr = LicenseExpression::License("mit".to_string());
779        assert_eq!(expression_to_string(&expr), "mit");
780    }
781
782    #[test]
783    fn test_expression_to_string_and() {
784        let expr = LicenseExpression::And {
785            left: Box::new(LicenseExpression::License("mit".to_string())),
786            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
787        };
788        assert_eq!(expression_to_string(&expr), "mit AND apache-2.0");
789    }
790
791    #[test]
792    fn test_expression_to_string_or() {
793        let expr = LicenseExpression::Or {
794            left: Box::new(LicenseExpression::License("mit".to_string())),
795            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
796        };
797        assert_eq!(expression_to_string(&expr), "mit OR apache-2.0");
798    }
799
800    #[test]
801    fn test_expression_to_string_with() {
802        let expr = LicenseExpression::With {
803            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
804            right: Box::new(LicenseExpression::License(
805                "classpath-exception-2.0".to_string(),
806            )),
807        };
808        assert_eq!(
809            expression_to_string(&expr),
810            "gpl-2.0 WITH classpath-exception-2.0"
811        );
812    }
813
814    #[test]
815    fn test_expression_to_string_licenseref() {
816        let expr = LicenseExpression::LicenseRef("licenseref-scancode-custom".to_string());
817        assert_eq!(expression_to_string(&expr), "licenseref-scancode-custom");
818    }
819
820    #[test]
821    fn test_expression_to_string_or_inside_and() {
822        let or_expr = LicenseExpression::Or {
823            left: Box::new(LicenseExpression::License("mit".to_string())),
824            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
825        };
826        let and_expr = LicenseExpression::And {
827            left: Box::new(or_expr),
828            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
829        };
830        assert_eq!(
831            expression_to_string(&and_expr),
832            "(mit OR apache-2.0) AND gpl-2.0"
833        );
834    }
835
836    #[test]
837    fn test_expression_to_string_and_inside_or() {
838        let and_expr = LicenseExpression::And {
839            left: Box::new(LicenseExpression::License("mit".to_string())),
840            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
841        };
842        let or_expr = LicenseExpression::Or {
843            left: Box::new(and_expr),
844            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
845        };
846        assert_eq!(
847            expression_to_string(&or_expr),
848            "(mit AND apache-2.0) OR gpl-2.0"
849        );
850    }
851
852    #[test]
853    fn test_expression_to_string_with_inside_or() {
854        let with_expr = LicenseExpression::With {
855            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
856            right: Box::new(LicenseExpression::License(
857                "classpath-exception-2.0".to_string(),
858            )),
859        };
860        let or_expr = LicenseExpression::Or {
861            left: Box::new(with_expr),
862            right: Box::new(LicenseExpression::License("mit".to_string())),
863        };
864        assert_eq!(
865            expression_to_string(&or_expr),
866            "gpl-2.0 WITH classpath-exception-2.0 OR mit"
867        );
868    }
869
870    #[test]
871    fn test_expression_to_string_with_inside_and() {
872        let with_expr = LicenseExpression::With {
873            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
874            right: Box::new(LicenseExpression::License(
875                "classpath-exception-2.0".to_string(),
876            )),
877        };
878        let and_expr = LicenseExpression::And {
879            left: Box::new(with_expr),
880            right: Box::new(LicenseExpression::License("mit".to_string())),
881        };
882        assert_eq!(
883            expression_to_string(&and_expr),
884            "gpl-2.0 WITH classpath-exception-2.0 AND mit"
885        );
886    }
887
888    #[test]
889    fn test_expression_to_string_nested_or_flattens_same_operator_grouping() {
890        let or_expr = LicenseExpression::Or {
891            left: Box::new(LicenseExpression::Or {
892                left: Box::new(LicenseExpression::License("mit".to_string())),
893                right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
894            }),
895            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
896        };
897        assert_eq!(
898            expression_to_string(&or_expr),
899            "mit OR apache-2.0 OR gpl-2.0"
900        );
901    }
902
903    #[test]
904    fn test_expression_to_string_nested_and_flattens_same_operator_grouping() {
905        let and_expr = LicenseExpression::And {
906            left: Box::new(LicenseExpression::And {
907                left: Box::new(LicenseExpression::License("mit".to_string())),
908                right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
909            }),
910            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
911        };
912        assert_eq!(
913            expression_to_string(&and_expr),
914            "mit AND apache-2.0 AND gpl-2.0"
915        );
916    }
917
918    #[test]
919    fn test_expression_to_string_roundtrip_or_and() {
920        let input = "(mit OR apache-2.0) AND gpl-2.0";
921        let expr = super::super::parse::parse_expression(input).unwrap();
922        let output = expression_to_string(&expr);
923        assert_eq!(output, "(mit OR apache-2.0) AND gpl-2.0");
924    }
925
926    #[test]
927    fn test_expression_to_string_roundtrip_or_with() {
928        let input = "gpl-2.0 WITH classpath-exception-2.0 OR mit";
929        let expr = super::super::parse::parse_expression(input).unwrap();
930        let output = expression_to_string(&expr);
931        assert_eq!(output, "gpl-2.0 WITH classpath-exception-2.0 OR mit");
932    }
933
934    #[test]
935    fn test_combine_expressions_empty() {
936        let result = combine_expressions_and(&[], true).unwrap();
937        assert_eq!(result, "");
938    }
939
940    #[test]
941    fn test_combine_expressions_single() {
942        let result = combine_expressions_and(&["mit"], true).unwrap();
943        assert_eq!(result, "mit");
944    }
945
946    #[test]
947    fn test_combine_expressions_two_and() {
948        let result = combine_expressions_and(&["mit", "gpl-2.0-plus"], true).unwrap();
949        assert_eq!(result, "gpl-2.0-plus AND mit");
950    }
951
952    #[test]
953    fn test_combine_expressions_two_or() {
954        let result = combine_expressions_or(&["mit", "apache-2.0"], true).unwrap();
955        assert_eq!(result, "apache-2.0 OR mit");
956    }
957
958    #[test]
959    fn test_combine_expressions_multiple_and() {
960        let result = combine_expressions_and(&["mit", "apache-2.0", "gpl-2.0-plus"], true).unwrap();
961        assert_eq!(result, "apache-2.0 AND gpl-2.0-plus AND mit");
962    }
963
964    #[test]
965    fn test_combine_expressions_with_duplicates_unique() {
966        let result = combine_expressions_or(&["mit", "mit", "apache-2.0"], true).unwrap();
967        let expr = super::super::parse::parse_expression(&result).unwrap();
968        let keys = expr.license_keys();
969        assert_eq!(keys.len(), 2);
970        assert!(keys.contains(&"mit".to_string()));
971        assert!(keys.contains(&"apache-2.0".to_string()));
972    }
973
974    #[test]
975    fn test_combine_expressions_with_duplicates_not_unique() {
976        let result = combine_expressions_or(&["mit", "mit", "apache-2.0"], false).unwrap();
977        let expr = super::super::parse::parse_expression(&result).unwrap();
978        assert_eq!(result, "mit OR mit OR apache-2.0");
979        let keys = expr.license_keys();
980        assert_eq!(keys.len(), 2);
981    }
982
983    #[test]
984    fn test_combine_expressions_complex_with_simplification() {
985        let result = combine_expressions_and(&["mit OR apache-2.0", "gpl-2.0-plus"], true).unwrap();
986        assert_eq!(result, "(apache-2.0 OR mit) AND gpl-2.0-plus");
987        let expr = super::super::parse::parse_expression(&result).unwrap();
988        assert!(matches!(expr, LicenseExpression::And { .. }));
989        let keys = expr.license_keys();
990        assert_eq!(keys.len(), 3);
991    }
992
993    #[test]
994    fn test_combine_expressions_parse_error() {
995        let result = combine_expressions_and(&["mit", "@invalid@"], true);
996        assert!(result.is_err());
997    }
998
999    #[test]
1000    fn test_combine_expressions_with_existing_and() {
1001        let result = combine_expressions_and(&["mit AND apache-2.0", "gpl-2.0"], true).unwrap();
1002        assert!(result.contains("mit"));
1003        assert!(result.contains("apache-2.0"));
1004        assert!(result.contains("gpl-2.0"));
1005    }
1006
1007    #[test]
1008    fn test_combine_expressions_with_existing_or() {
1009        let result = combine_expressions_or(&["mit OR apache-2.0", "gpl-2.0"], true).unwrap();
1010        assert!(result.contains("mit"));
1011        assert!(result.contains("apache-2.0"));
1012        assert!(result.contains("gpl-2.0"));
1013    }
1014
1015    #[test]
1016    fn test_expression_to_string_with_no_outer_parens() {
1017        let with_expr = LicenseExpression::With {
1018            left: Box::new(LicenseExpression::License("gpl-2.0-plus".to_string())),
1019            right: Box::new(LicenseExpression::License(
1020                "classpath-exception-2.0".to_string(),
1021            )),
1022        };
1023        assert_eq!(
1024            expression_to_string(&with_expr),
1025            "gpl-2.0-plus WITH classpath-exception-2.0"
1026        );
1027    }
1028
1029    #[test]
1030    fn test_expression_to_string_with_as_right_operand_of_or() {
1031        let with_expr = LicenseExpression::With {
1032            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
1033            right: Box::new(LicenseExpression::License(
1034                "classpath-exception-2.0".to_string(),
1035            )),
1036        };
1037        let or_expr = LicenseExpression::Or {
1038            left: Box::new(LicenseExpression::License("mit".to_string())),
1039            right: Box::new(with_expr),
1040        };
1041        assert_eq!(
1042            expression_to_string(&or_expr),
1043            "mit OR gpl-2.0 WITH classpath-exception-2.0"
1044        );
1045    }
1046
1047    #[test]
1048    fn test_expression_to_string_with_as_right_operand_of_and() {
1049        let with_expr = LicenseExpression::With {
1050            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
1051            right: Box::new(LicenseExpression::License(
1052                "classpath-exception-2.0".to_string(),
1053            )),
1054        };
1055        let and_expr = LicenseExpression::And {
1056            left: Box::new(LicenseExpression::License("mit".to_string())),
1057            right: Box::new(with_expr),
1058        };
1059        assert_eq!(
1060            expression_to_string(&and_expr),
1061            "mit AND gpl-2.0 WITH classpath-exception-2.0"
1062        );
1063    }
1064
1065    #[test]
1066    fn test_expression_to_string_complex_precedence() {
1067        let input = "mit OR apache-2.0 AND gpl-2.0";
1068        let expr = super::super::parse::parse_expression(input).unwrap();
1069        assert_eq!(
1070            expression_to_string(&expr),
1071            "mit OR (apache-2.0 AND gpl-2.0)"
1072        );
1073    }
1074
1075    #[test]
1076    fn test_expression_to_string_with_no_outer_parens_in_complex_and() {
1077        // WITH has higher precedence than AND
1078        // Parsed as: (bsd-new AND mit) AND (gpl-3.0-plus WITH autoconf-simple-exception)
1079        let input = "bsd-new AND mit AND gpl-3.0-plus WITH autoconf-simple-exception";
1080        let expr = super::super::parse::parse_expression(input).unwrap();
1081        assert_eq!(
1082            expression_to_string(&expr),
1083            "bsd-new AND mit AND gpl-3.0-plus WITH autoconf-simple-exception"
1084        );
1085    }
1086
1087    #[test]
1088    fn test_combine_expressions_and_flattens_reported_redundant_parentheses() {
1089        let result = combine_expressions_and(
1090            &[
1091                "Apache-2.0",
1092                "BSD-3-Clause",
1093                "GPL-2.0-only",
1094                "LicenseRef-scancode-oracle-openjdk-exception-2.0",
1095                "APSL-1.0",
1096                "APSL-2.0",
1097            ],
1098            true,
1099        )
1100        .unwrap();
1101
1102        assert_eq!(
1103            result,
1104            "apache-2.0 AND apsl-1.0 AND apsl-2.0 AND bsd-3-clause AND gpl-2.0-only AND licenseref-scancode-oracle-openjdk-exception-2.0"
1105        );
1106    }
1107
1108    #[test]
1109    fn test_build_expression_from_list_balances_large_and_chains() {
1110        let unique: Vec<_> = (0..1024)
1111            .map(|idx| LicenseExpression::License(format!("license-{idx}")))
1112            .collect();
1113
1114        let result = build_expression_from_list(&unique, true);
1115
1116        assert!(expression_depth(&result) <= 12);
1117    }
1118
1119    #[test]
1120    fn test_build_expression_from_list_balances_large_or_chains() {
1121        let unique: Vec<_> = (0..1024)
1122            .map(|idx| LicenseExpression::License(format!("license-{idx}")))
1123            .collect();
1124
1125        let result = build_expression_from_list(&unique, false);
1126
1127        assert!(expression_depth(&result) <= 12);
1128    }
1129}
1130
1131#[cfg(test)]
1132mod contains_tests {
1133    use super::*;
1134
1135    #[test]
1136    fn test_basic_containment() {
1137        assert!(licensing_contains("mit", "mit"));
1138        assert!(!licensing_contains("mit", "apache"));
1139    }
1140
1141    #[test]
1142    fn test_or_containment() {
1143        assert!(licensing_contains("mit OR apache", "mit"));
1144        assert!(licensing_contains("mit OR apache", "apache"));
1145        assert!(!licensing_contains("mit OR apache", "gpl"));
1146    }
1147
1148    #[test]
1149    fn test_and_containment() {
1150        assert!(licensing_contains("mit AND apache", "mit"));
1151        assert!(licensing_contains("mit AND apache", "apache"));
1152        assert!(!licensing_contains("mit", "mit AND apache"));
1153    }
1154
1155    #[test]
1156    fn test_expression_subset() {
1157        assert!(licensing_contains(
1158            "mit AND apache AND bsd",
1159            "mit AND apache"
1160        ));
1161        assert!(!licensing_contains(
1162            "mit AND apache",
1163            "mit AND apache AND bsd"
1164        ));
1165        assert!(licensing_contains("mit OR apache OR bsd", "mit OR apache"));
1166        assert!(!licensing_contains("mit OR apache", "mit OR apache OR bsd"));
1167    }
1168
1169    #[test]
1170    fn test_order_independence() {
1171        assert!(licensing_contains("mit AND apache", "apache AND mit"));
1172        assert!(licensing_contains("mit OR apache", "apache OR mit"));
1173    }
1174
1175    #[test]
1176    fn test_plus_suffix_no_containment() {
1177        assert!(!licensing_contains("gpl-2.0-plus", "gpl-2.0"));
1178        assert!(!licensing_contains("gpl-2.0", "gpl-2.0-plus"));
1179    }
1180
1181    #[test]
1182    fn test_with_decomposition() {
1183        assert!(licensing_contains(
1184            "gpl-2.0 WITH classpath-exception",
1185            "gpl-2.0"
1186        ));
1187        assert!(licensing_contains(
1188            "gpl-2.0 WITH classpath-exception",
1189            "classpath-exception"
1190        ));
1191        assert!(!licensing_contains(
1192            "gpl-2.0",
1193            "gpl-2.0 WITH classpath-exception"
1194        ));
1195    }
1196
1197    #[test]
1198    fn test_mixed_operators() {
1199        assert!(!licensing_contains("mit OR apache", "mit AND apache"));
1200        assert!(!licensing_contains("mit AND apache", "mit OR apache"));
1201    }
1202
1203    #[test]
1204    fn test_nested_expressions() {
1205        assert!(!licensing_contains("(mit OR apache) AND bsd", "mit"));
1206        assert!(licensing_contains(
1207            "(mit OR apache) AND bsd",
1208            "mit OR apache"
1209        ));
1210        assert!(licensing_contains("(mit OR apache) AND bsd", "bsd"));
1211    }
1212
1213    #[test]
1214    fn test_empty_expressions() {
1215        assert!(!licensing_contains("", "mit"));
1216        assert!(!licensing_contains("mit", ""));
1217        assert!(!licensing_contains("", ""));
1218        assert!(!licensing_contains("   ", "mit"));
1219    }
1220
1221    #[test]
1222    fn test_invalid_expressions() {
1223        assert!(!licensing_contains("mit AND", "mit"));
1224        assert!(!licensing_contains("mit", "AND apache"));
1225    }
1226}