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.
577#[allow(dead_code)]
578pub fn combine_expressions_or(expressions: &[&str], unique: bool) -> Result<String, ParseError> {
579    combine_expressions_with(
580        expressions,
581        unique,
582        LicenseExpression::or,
583        simplify_expression,
584    )
585}
586
587/// Combine multiple license expressions with `OR` while preserving the
588/// original boolean structure of distinct operands.
589pub fn combine_expressions_or_preserving_structure(
590    expressions: &[&str],
591    unique: bool,
592) -> Result<String, ParseError> {
593    combine_expressions_with(
594        expressions,
595        unique,
596        LicenseExpression::or,
597        simplify_expression_preserving_structure,
598    )
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    fn expression_depth(expr: &LicenseExpression) -> usize {
606        match expr {
607            LicenseExpression::License(_) | LicenseExpression::LicenseRef(_) => 1,
608            LicenseExpression::And { left, right }
609            | LicenseExpression::Or { left, right }
610            | LicenseExpression::With { left, right } => {
611                1 + expression_depth(left).max(expression_depth(right))
612            }
613        }
614    }
615
616    #[test]
617    fn test_simplify_expression_no_change() {
618        let expr = super::super::parse::parse_expression("MIT AND Apache-2.0").unwrap();
619        let simplified = simplify_expression(&expr);
620        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
621    }
622
623    #[test]
624    fn test_simplify_expression_with_duplicates() {
625        let expr = super::super::parse::parse_expression("MIT OR MIT").unwrap();
626        let simplified = simplify_expression(&expr);
627        assert_eq!(expression_to_string(&simplified), "mit");
628    }
629
630    #[test]
631    fn test_simplify_expression_preserving_structure_keeps_distinct_nested_operands() {
632        let expr = super::super::parse::parse_expression("mit AND (apache-2.0 OR mit)").unwrap();
633        let simplified = simplify_expression_preserving_structure(&expr);
634        assert_eq!(
635            expression_to_string(&simplified),
636            "mit AND (apache-2.0 OR mit)"
637        );
638    }
639
640    #[test]
641    fn test_simplify_and_duplicates() {
642        let expr = super::super::parse::parse_expression("crapl-0.1 AND crapl-0.1").unwrap();
643        let simplified = simplify_expression(&expr);
644        assert_eq!(expression_to_string(&simplified), "crapl-0.1");
645    }
646
647    #[test]
648    fn test_simplify_or_duplicates() {
649        let expr = super::super::parse::parse_expression("mit OR mit").unwrap();
650        let simplified = simplify_expression(&expr);
651        assert_eq!(expression_to_string(&simplified), "mit");
652    }
653
654    #[test]
655    fn test_combine_expressions_and_preserving_structure_keeps_distinct_nested_operands() {
656        let result =
657            combine_expressions_and_preserving_structure(&["mit", "apache-2.0 OR mit"], true)
658                .unwrap();
659        assert_eq!(result, "mit AND (apache-2.0 OR mit)");
660    }
661
662    #[test]
663    fn test_simplify_preserves_different_licenses() {
664        let expr = super::super::parse::parse_expression("mit AND apache-2.0").unwrap();
665        let simplified = simplify_expression(&expr);
666        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
667    }
668
669    #[test]
670    fn test_simplify_complex_duplicates() {
671        let expr = super::super::parse::parse_expression(
672            "gpl-2.0-plus AND gpl-2.0-plus AND lgpl-2.0-plus",
673        )
674        .unwrap();
675        let simplified = simplify_expression(&expr);
676        assert_eq!(
677            expression_to_string(&simplified),
678            "gpl-2.0-plus AND lgpl-2.0-plus"
679        );
680    }
681
682    #[test]
683    fn test_simplify_three_duplicates() {
684        let expr =
685            super::super::parse::parse_expression("fsf-free AND fsf-free AND fsf-free").unwrap();
686        let simplified = simplify_expression(&expr);
687        assert_eq!(expression_to_string(&simplified), "fsf-free");
688    }
689
690    #[test]
691    fn test_simplify_with_expression_dedup() {
692        let expr = super::super::parse::parse_expression(
693            "gpl-2.0 WITH classpath-exception-2.0 AND gpl-2.0 WITH classpath-exception-2.0",
694        )
695        .unwrap();
696        let simplified = simplify_expression(&expr);
697        assert_eq!(
698            expression_to_string(&simplified),
699            "gpl-2.0 WITH classpath-exception-2.0"
700        );
701    }
702
703    #[test]
704    fn test_simplify_nested_duplicates() {
705        let expr =
706            super::super::parse::parse_expression("(mit AND apache-2.0) OR (mit AND apache-2.0)")
707                .unwrap();
708        let simplified = simplify_expression(&expr);
709        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
710    }
711
712    #[test]
713    fn test_simplify_sorts_operands_canonically() {
714        let expr =
715            super::super::parse::parse_expression("apache-2.0 AND mit AND apache-2.0").unwrap();
716        let simplified = simplify_expression(&expr);
717        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
718    }
719
720    #[test]
721    fn test_simplify_mit_and_mit_and_apache() {
722        let expr = super::super::parse::parse_expression("mit AND mit AND apache-2.0").unwrap();
723        let simplified = simplify_expression(&expr);
724        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
725    }
726
727    #[test]
728    fn test_simplify_and_absorption() {
729        let expr = super::super::parse::parse_expression("mit AND (mit OR apache-2.0)").unwrap();
730        let simplified = simplify_expression(&expr);
731
732        assert_eq!(expression_to_string(&simplified), "mit");
733    }
734
735    #[test]
736    fn test_simplify_or_absorption() {
737        let expr = super::super::parse::parse_expression("mit OR (mit AND apache-2.0)").unwrap();
738        let simplified = simplify_expression(&expr);
739
740        assert_eq!(expression_to_string(&simplified), "mit");
741    }
742
743    #[test]
744    fn test_simplify_or_subsumption() {
745        let expr = super::super::parse::parse_expression(
746            "(mit AND apache-2.0) OR (mit AND apache-2.0 AND bsd-new)",
747        )
748        .unwrap();
749        let simplified = simplify_expression(&expr);
750
751        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
752    }
753
754    #[test]
755    fn test_simplify_and_subsumption() {
756        let expr = super::super::parse::parse_expression(
757            "(mit OR apache-2.0) AND (mit OR apache-2.0 OR bsd-new)",
758        )
759        .unwrap();
760        let simplified = simplify_expression(&expr);
761
762        assert_eq!(expression_to_string(&simplified), "apache-2.0 OR mit");
763    }
764
765    #[test]
766    fn test_simplify_and_keeps_gpl_or_later_with_only() {
767        let expr =
768            super::super::parse::parse_expression("gpl-2.0-or-later AND gpl-2.0-only").unwrap();
769        let simplified = simplify_expression(&expr);
770
771        assert_eq!(
772            expression_to_string(&simplified),
773            "gpl-2.0-only AND gpl-2.0-or-later"
774        );
775    }
776
777    #[test]
778    fn test_expression_to_string_simple() {
779        let expr = LicenseExpression::License("mit".to_string());
780        assert_eq!(expression_to_string(&expr), "mit");
781    }
782
783    #[test]
784    fn test_expression_to_string_and() {
785        let expr = LicenseExpression::And {
786            left: Box::new(LicenseExpression::License("mit".to_string())),
787            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
788        };
789        assert_eq!(expression_to_string(&expr), "mit AND apache-2.0");
790    }
791
792    #[test]
793    fn test_expression_to_string_or() {
794        let expr = LicenseExpression::Or {
795            left: Box::new(LicenseExpression::License("mit".to_string())),
796            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
797        };
798        assert_eq!(expression_to_string(&expr), "mit OR apache-2.0");
799    }
800
801    #[test]
802    fn test_expression_to_string_with() {
803        let expr = LicenseExpression::With {
804            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
805            right: Box::new(LicenseExpression::License(
806                "classpath-exception-2.0".to_string(),
807            )),
808        };
809        assert_eq!(
810            expression_to_string(&expr),
811            "gpl-2.0 WITH classpath-exception-2.0"
812        );
813    }
814
815    #[test]
816    fn test_expression_to_string_licenseref() {
817        let expr = LicenseExpression::LicenseRef("licenseref-scancode-custom".to_string());
818        assert_eq!(expression_to_string(&expr), "licenseref-scancode-custom");
819    }
820
821    #[test]
822    fn test_expression_to_string_or_inside_and() {
823        let or_expr = LicenseExpression::Or {
824            left: Box::new(LicenseExpression::License("mit".to_string())),
825            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
826        };
827        let and_expr = LicenseExpression::And {
828            left: Box::new(or_expr),
829            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
830        };
831        assert_eq!(
832            expression_to_string(&and_expr),
833            "(mit OR apache-2.0) AND gpl-2.0"
834        );
835    }
836
837    #[test]
838    fn test_expression_to_string_and_inside_or() {
839        let and_expr = LicenseExpression::And {
840            left: Box::new(LicenseExpression::License("mit".to_string())),
841            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
842        };
843        let or_expr = LicenseExpression::Or {
844            left: Box::new(and_expr),
845            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
846        };
847        assert_eq!(
848            expression_to_string(&or_expr),
849            "(mit AND apache-2.0) OR gpl-2.0"
850        );
851    }
852
853    #[test]
854    fn test_expression_to_string_with_inside_or() {
855        let with_expr = LicenseExpression::With {
856            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
857            right: Box::new(LicenseExpression::License(
858                "classpath-exception-2.0".to_string(),
859            )),
860        };
861        let or_expr = LicenseExpression::Or {
862            left: Box::new(with_expr),
863            right: Box::new(LicenseExpression::License("mit".to_string())),
864        };
865        assert_eq!(
866            expression_to_string(&or_expr),
867            "gpl-2.0 WITH classpath-exception-2.0 OR mit"
868        );
869    }
870
871    #[test]
872    fn test_expression_to_string_with_inside_and() {
873        let with_expr = LicenseExpression::With {
874            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
875            right: Box::new(LicenseExpression::License(
876                "classpath-exception-2.0".to_string(),
877            )),
878        };
879        let and_expr = LicenseExpression::And {
880            left: Box::new(with_expr),
881            right: Box::new(LicenseExpression::License("mit".to_string())),
882        };
883        assert_eq!(
884            expression_to_string(&and_expr),
885            "gpl-2.0 WITH classpath-exception-2.0 AND mit"
886        );
887    }
888
889    #[test]
890    fn test_expression_to_string_nested_or_flattens_same_operator_grouping() {
891        let or_expr = LicenseExpression::Or {
892            left: Box::new(LicenseExpression::Or {
893                left: Box::new(LicenseExpression::License("mit".to_string())),
894                right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
895            }),
896            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
897        };
898        assert_eq!(
899            expression_to_string(&or_expr),
900            "mit OR apache-2.0 OR gpl-2.0"
901        );
902    }
903
904    #[test]
905    fn test_expression_to_string_nested_and_flattens_same_operator_grouping() {
906        let and_expr = LicenseExpression::And {
907            left: Box::new(LicenseExpression::And {
908                left: Box::new(LicenseExpression::License("mit".to_string())),
909                right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
910            }),
911            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
912        };
913        assert_eq!(
914            expression_to_string(&and_expr),
915            "mit AND apache-2.0 AND gpl-2.0"
916        );
917    }
918
919    #[test]
920    fn test_expression_to_string_roundtrip_or_and() {
921        let input = "(mit OR apache-2.0) AND gpl-2.0";
922        let expr = super::super::parse::parse_expression(input).unwrap();
923        let output = expression_to_string(&expr);
924        assert_eq!(output, "(mit OR apache-2.0) AND gpl-2.0");
925    }
926
927    #[test]
928    fn test_expression_to_string_roundtrip_or_with() {
929        let input = "gpl-2.0 WITH classpath-exception-2.0 OR mit";
930        let expr = super::super::parse::parse_expression(input).unwrap();
931        let output = expression_to_string(&expr);
932        assert_eq!(output, "gpl-2.0 WITH classpath-exception-2.0 OR mit");
933    }
934
935    #[test]
936    fn test_combine_expressions_empty() {
937        let result = combine_expressions_and(&[], true).unwrap();
938        assert_eq!(result, "");
939    }
940
941    #[test]
942    fn test_combine_expressions_single() {
943        let result = combine_expressions_and(&["mit"], true).unwrap();
944        assert_eq!(result, "mit");
945    }
946
947    #[test]
948    fn test_combine_expressions_two_and() {
949        let result = combine_expressions_and(&["mit", "gpl-2.0-plus"], true).unwrap();
950        assert_eq!(result, "gpl-2.0-plus AND mit");
951    }
952
953    #[test]
954    fn test_combine_expressions_two_or() {
955        let result = combine_expressions_or(&["mit", "apache-2.0"], true).unwrap();
956        assert_eq!(result, "apache-2.0 OR mit");
957    }
958
959    #[test]
960    fn test_combine_expressions_multiple_and() {
961        let result = combine_expressions_and(&["mit", "apache-2.0", "gpl-2.0-plus"], true).unwrap();
962        assert_eq!(result, "apache-2.0 AND gpl-2.0-plus AND mit");
963    }
964
965    #[test]
966    fn test_combine_expressions_with_duplicates_unique() {
967        let result = combine_expressions_or(&["mit", "mit", "apache-2.0"], true).unwrap();
968        let expr = super::super::parse::parse_expression(&result).unwrap();
969        let keys = expr.license_keys();
970        assert_eq!(keys.len(), 2);
971        assert!(keys.contains(&"mit".to_string()));
972        assert!(keys.contains(&"apache-2.0".to_string()));
973    }
974
975    #[test]
976    fn test_combine_expressions_with_duplicates_not_unique() {
977        let result = combine_expressions_or(&["mit", "mit", "apache-2.0"], false).unwrap();
978        let expr = super::super::parse::parse_expression(&result).unwrap();
979        assert_eq!(result, "mit OR mit OR apache-2.0");
980        let keys = expr.license_keys();
981        assert_eq!(keys.len(), 2);
982    }
983
984    #[test]
985    fn test_combine_expressions_complex_with_simplification() {
986        let result = combine_expressions_and(&["mit OR apache-2.0", "gpl-2.0-plus"], true).unwrap();
987        assert_eq!(result, "(apache-2.0 OR mit) AND gpl-2.0-plus");
988        let expr = super::super::parse::parse_expression(&result).unwrap();
989        assert!(matches!(expr, LicenseExpression::And { .. }));
990        let keys = expr.license_keys();
991        assert_eq!(keys.len(), 3);
992    }
993
994    #[test]
995    fn test_combine_expressions_parse_error() {
996        let result = combine_expressions_and(&["mit", "@invalid@"], true);
997        assert!(result.is_err());
998    }
999
1000    #[test]
1001    fn test_combine_expressions_with_existing_and() {
1002        let result = combine_expressions_and(&["mit AND apache-2.0", "gpl-2.0"], true).unwrap();
1003        assert!(result.contains("mit"));
1004        assert!(result.contains("apache-2.0"));
1005        assert!(result.contains("gpl-2.0"));
1006    }
1007
1008    #[test]
1009    fn test_combine_expressions_with_existing_or() {
1010        let result = combine_expressions_or(&["mit OR apache-2.0", "gpl-2.0"], true).unwrap();
1011        assert!(result.contains("mit"));
1012        assert!(result.contains("apache-2.0"));
1013        assert!(result.contains("gpl-2.0"));
1014    }
1015
1016    #[test]
1017    fn test_expression_to_string_with_no_outer_parens() {
1018        let with_expr = LicenseExpression::With {
1019            left: Box::new(LicenseExpression::License("gpl-2.0-plus".to_string())),
1020            right: Box::new(LicenseExpression::License(
1021                "classpath-exception-2.0".to_string(),
1022            )),
1023        };
1024        assert_eq!(
1025            expression_to_string(&with_expr),
1026            "gpl-2.0-plus WITH classpath-exception-2.0"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_expression_to_string_with_as_right_operand_of_or() {
1032        let with_expr = LicenseExpression::With {
1033            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
1034            right: Box::new(LicenseExpression::License(
1035                "classpath-exception-2.0".to_string(),
1036            )),
1037        };
1038        let or_expr = LicenseExpression::Or {
1039            left: Box::new(LicenseExpression::License("mit".to_string())),
1040            right: Box::new(with_expr),
1041        };
1042        assert_eq!(
1043            expression_to_string(&or_expr),
1044            "mit OR gpl-2.0 WITH classpath-exception-2.0"
1045        );
1046    }
1047
1048    #[test]
1049    fn test_expression_to_string_with_as_right_operand_of_and() {
1050        let with_expr = LicenseExpression::With {
1051            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
1052            right: Box::new(LicenseExpression::License(
1053                "classpath-exception-2.0".to_string(),
1054            )),
1055        };
1056        let and_expr = LicenseExpression::And {
1057            left: Box::new(LicenseExpression::License("mit".to_string())),
1058            right: Box::new(with_expr),
1059        };
1060        assert_eq!(
1061            expression_to_string(&and_expr),
1062            "mit AND gpl-2.0 WITH classpath-exception-2.0"
1063        );
1064    }
1065
1066    #[test]
1067    fn test_expression_to_string_complex_precedence() {
1068        let input = "mit OR apache-2.0 AND gpl-2.0";
1069        let expr = super::super::parse::parse_expression(input).unwrap();
1070        assert_eq!(
1071            expression_to_string(&expr),
1072            "mit OR (apache-2.0 AND gpl-2.0)"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_expression_to_string_with_no_outer_parens_in_complex_and() {
1078        // WITH has higher precedence than AND
1079        // Parsed as: (bsd-new AND mit) AND (gpl-3.0-plus WITH autoconf-simple-exception)
1080        let input = "bsd-new AND mit AND gpl-3.0-plus WITH autoconf-simple-exception";
1081        let expr = super::super::parse::parse_expression(input).unwrap();
1082        assert_eq!(
1083            expression_to_string(&expr),
1084            "bsd-new AND mit AND gpl-3.0-plus WITH autoconf-simple-exception"
1085        );
1086    }
1087
1088    #[test]
1089    fn test_combine_expressions_and_flattens_reported_redundant_parentheses() {
1090        let result = combine_expressions_and(
1091            &[
1092                "Apache-2.0",
1093                "BSD-3-Clause",
1094                "GPL-2.0-only",
1095                "LicenseRef-scancode-oracle-openjdk-exception-2.0",
1096                "APSL-1.0",
1097                "APSL-2.0",
1098            ],
1099            true,
1100        )
1101        .unwrap();
1102
1103        assert_eq!(
1104            result,
1105            "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"
1106        );
1107    }
1108
1109    #[test]
1110    fn test_build_expression_from_list_balances_large_and_chains() {
1111        let unique: Vec<_> = (0..1024)
1112            .map(|idx| LicenseExpression::License(format!("license-{idx}")))
1113            .collect();
1114
1115        let result = build_expression_from_list(&unique, true);
1116
1117        assert!(expression_depth(&result) <= 12);
1118    }
1119
1120    #[test]
1121    fn test_build_expression_from_list_balances_large_or_chains() {
1122        let unique: Vec<_> = (0..1024)
1123            .map(|idx| LicenseExpression::License(format!("license-{idx}")))
1124            .collect();
1125
1126        let result = build_expression_from_list(&unique, false);
1127
1128        assert!(expression_depth(&result) <= 12);
1129    }
1130}
1131
1132#[cfg(test)]
1133mod contains_tests {
1134    use super::*;
1135
1136    #[test]
1137    fn test_basic_containment() {
1138        assert!(licensing_contains("mit", "mit"));
1139        assert!(!licensing_contains("mit", "apache"));
1140    }
1141
1142    #[test]
1143    fn test_or_containment() {
1144        assert!(licensing_contains("mit OR apache", "mit"));
1145        assert!(licensing_contains("mit OR apache", "apache"));
1146        assert!(!licensing_contains("mit OR apache", "gpl"));
1147    }
1148
1149    #[test]
1150    fn test_and_containment() {
1151        assert!(licensing_contains("mit AND apache", "mit"));
1152        assert!(licensing_contains("mit AND apache", "apache"));
1153        assert!(!licensing_contains("mit", "mit AND apache"));
1154    }
1155
1156    #[test]
1157    fn test_expression_subset() {
1158        assert!(licensing_contains(
1159            "mit AND apache AND bsd",
1160            "mit AND apache"
1161        ));
1162        assert!(!licensing_contains(
1163            "mit AND apache",
1164            "mit AND apache AND bsd"
1165        ));
1166        assert!(licensing_contains("mit OR apache OR bsd", "mit OR apache"));
1167        assert!(!licensing_contains("mit OR apache", "mit OR apache OR bsd"));
1168    }
1169
1170    #[test]
1171    fn test_order_independence() {
1172        assert!(licensing_contains("mit AND apache", "apache AND mit"));
1173        assert!(licensing_contains("mit OR apache", "apache OR mit"));
1174    }
1175
1176    #[test]
1177    fn test_plus_suffix_no_containment() {
1178        assert!(!licensing_contains("gpl-2.0-plus", "gpl-2.0"));
1179        assert!(!licensing_contains("gpl-2.0", "gpl-2.0-plus"));
1180    }
1181
1182    #[test]
1183    fn test_with_decomposition() {
1184        assert!(licensing_contains(
1185            "gpl-2.0 WITH classpath-exception",
1186            "gpl-2.0"
1187        ));
1188        assert!(licensing_contains(
1189            "gpl-2.0 WITH classpath-exception",
1190            "classpath-exception"
1191        ));
1192        assert!(!licensing_contains(
1193            "gpl-2.0",
1194            "gpl-2.0 WITH classpath-exception"
1195        ));
1196    }
1197
1198    #[test]
1199    fn test_mixed_operators() {
1200        assert!(!licensing_contains("mit OR apache", "mit AND apache"));
1201        assert!(!licensing_contains("mit AND apache", "mit OR apache"));
1202    }
1203
1204    #[test]
1205    fn test_nested_expressions() {
1206        assert!(!licensing_contains("(mit OR apache) AND bsd", "mit"));
1207        assert!(licensing_contains(
1208            "(mit OR apache) AND bsd",
1209            "mit OR apache"
1210        ));
1211        assert!(licensing_contains("(mit OR apache) AND bsd", "bsd"));
1212    }
1213
1214    #[test]
1215    fn test_empty_expressions() {
1216        assert!(!licensing_contains("", "mit"));
1217        assert!(!licensing_contains("mit", ""));
1218        assert!(!licensing_contains("", ""));
1219        assert!(!licensing_contains("   ", "mit"));
1220    }
1221
1222    #[test]
1223    fn test_invalid_expressions() {
1224        assert!(!licensing_contains("mit AND", "mit"));
1225        assert!(!licensing_contains("mit", "AND apache"));
1226    }
1227}