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    match unique.len() {
249        0 => panic!("build_expression_from_list called with empty list"),
250        1 => unique[0].clone(),
251        _ => {
252            let mut iter = unique.iter();
253            let mut result = iter.next().unwrap().clone();
254            for expr in iter {
255                result = if is_and {
256                    LicenseExpression::And {
257                        left: Box::new(result),
258                        right: Box::new(expr.clone()),
259                    }
260                } else {
261                    LicenseExpression::Or {
262                        left: Box::new(result),
263                        right: Box::new(expr.clone()),
264                    }
265                };
266            }
267            result
268        }
269    }
270}
271
272fn get_flat_args(expr: &LicenseExpression) -> Vec<LicenseExpression> {
273    match expr {
274        LicenseExpression::And { left, right } => {
275            let mut args = Vec::new();
276            collect_flat_and_args(left, &mut args);
277            collect_flat_and_args(right, &mut args);
278            args
279        }
280        LicenseExpression::Or { left, right } => {
281            let mut args = Vec::new();
282            collect_flat_or_args(left, &mut args);
283            collect_flat_or_args(right, &mut args);
284            args
285        }
286        _ => vec![expr.clone()],
287    }
288}
289
290fn collect_flat_and_args(expr: &LicenseExpression, args: &mut Vec<LicenseExpression>) {
291    match expr {
292        LicenseExpression::And { left, right } => {
293            collect_flat_and_args(left, args);
294            collect_flat_and_args(right, args);
295        }
296        _ => args.push(expr.clone()),
297    }
298}
299
300fn collect_flat_or_args(expr: &LicenseExpression, args: &mut Vec<LicenseExpression>) {
301    match expr {
302        LicenseExpression::Or { left, right } => {
303            collect_flat_or_args(left, args);
304            collect_flat_or_args(right, args);
305        }
306        _ => args.push(expr.clone()),
307    }
308}
309
310fn decompose_expr(expr: &LicenseExpression) -> Vec<LicenseExpression> {
311    match expr {
312        LicenseExpression::With { left, right } => {
313            let mut parts = decompose_expr(left);
314            parts.extend(decompose_expr(right));
315            parts
316        }
317        _ => vec![expr.clone()],
318    }
319}
320
321fn expressions_equal(a: &LicenseExpression, b: &LicenseExpression) -> bool {
322    match (a, b) {
323        (LicenseExpression::License(ka), LicenseExpression::License(kb)) => ka == kb,
324        (LicenseExpression::LicenseRef(ka), LicenseExpression::LicenseRef(kb)) => ka == kb,
325        (
326            LicenseExpression::With {
327                left: l1,
328                right: r1,
329            },
330            LicenseExpression::With {
331                left: l2,
332                right: r2,
333            },
334        ) => expressions_equal(l1, l2) && expressions_equal(r1, r2),
335        (LicenseExpression::And { .. }, LicenseExpression::And { .. }) => {
336            let args_a = get_flat_args(a);
337            let args_b = get_flat_args(b);
338            args_a.len() == args_b.len()
339                && args_b
340                    .iter()
341                    .all(|b_arg| args_a.iter().any(|a_arg| expressions_equal(a_arg, b_arg)))
342        }
343        (LicenseExpression::Or { .. }, LicenseExpression::Or { .. }) => {
344            let args_a = get_flat_args(a);
345            let args_b = get_flat_args(b);
346            args_a.len() == args_b.len()
347                && args_b
348                    .iter()
349                    .all(|b_arg| args_a.iter().any(|a_arg| expressions_equal(a_arg, b_arg)))
350        }
351        _ => false,
352    }
353}
354
355fn expr_in_args(expr: &LicenseExpression, args: &[LicenseExpression]) -> bool {
356    if args.iter().any(|a| expressions_equal(a, expr)) {
357        return true;
358    }
359    let decomposed = decompose_expr(expr);
360    if decomposed.len() == 1 {
361        return false;
362    }
363    decomposed
364        .iter()
365        .any(|d| args.iter().any(|a| expressions_equal(a, d)))
366}
367
368pub fn licensing_contains(container: &str, contained: &str) -> bool {
369    let container = container.trim();
370    let contained = contained.trim();
371    if container.is_empty() || contained.is_empty() {
372        return false;
373    }
374
375    if container == contained {
376        return true;
377    }
378
379    let Ok(parsed_container) = super::parse::parse_expression(container) else {
380        return false;
381    };
382    let Ok(parsed_contained) = super::parse::parse_expression(contained) else {
383        return false;
384    };
385
386    let simplified_container = simplify_expression(&parsed_container);
387    let simplified_contained = simplify_expression(&parsed_contained);
388
389    match (&simplified_container, &simplified_contained) {
390        (LicenseExpression::And { .. }, LicenseExpression::And { .. })
391        | (LicenseExpression::Or { .. }, LicenseExpression::Or { .. }) => {
392            let container_args = get_flat_args(&simplified_container);
393            let contained_args = get_flat_args(&simplified_contained);
394            contained_args
395                .iter()
396                .all(|c| container_args.iter().any(|ca| expressions_equal(ca, c)))
397        }
398        (
399            LicenseExpression::And { .. } | LicenseExpression::Or { .. },
400            LicenseExpression::License(_) | LicenseExpression::LicenseRef(_),
401        ) => {
402            let container_args = get_flat_args(&simplified_container);
403            expr_in_args(&simplified_contained, &container_args)
404        }
405        (LicenseExpression::And { .. } | LicenseExpression::Or { .. }, _) => {
406            let container_args = get_flat_args(&simplified_container);
407            container_args
408                .iter()
409                .any(|ca| expressions_equal(ca, &simplified_contained))
410        }
411        (
412            LicenseExpression::With { .. },
413            LicenseExpression::License(_) | LicenseExpression::LicenseRef(_),
414        ) => {
415            let decomposed = decompose_expr(&simplified_container);
416            decomposed
417                .iter()
418                .any(|d| expressions_equal(d, &simplified_contained))
419        }
420        (
421            LicenseExpression::License(_) | LicenseExpression::LicenseRef(_),
422            LicenseExpression::And { .. }
423            | LicenseExpression::Or { .. }
424            | LicenseExpression::With { .. },
425        ) => false,
426        (LicenseExpression::License(k1), LicenseExpression::License(k2)) => k1 == k2,
427        (LicenseExpression::LicenseRef(k1), LicenseExpression::LicenseRef(k2)) => k1 == k2,
428        _ => false,
429    }
430}
431
432/// # Returns
433/// String representation of the expression
434///
435/// # Parentheses
436/// Parentheses are added when needed to preserve semantic meaning based on
437/// operator precedence (WITH > AND > OR). This matches the Python
438/// license-expression library behavior.
439/// Convert a license expression to its string representation.
440#[derive(Clone, Copy)]
441enum BooleanOperator {
442    And,
443    Or,
444}
445
446pub fn expression_to_string(expr: &LicenseExpression) -> String {
447    match expr {
448        LicenseExpression::License(key) => key.clone(),
449        LicenseExpression::LicenseRef(key) => key.clone(),
450        LicenseExpression::And { .. } => render_flat_boolean_chain(expr, BooleanOperator::And),
451        LicenseExpression::Or { .. } => render_flat_boolean_chain(expr, BooleanOperator::Or),
452        LicenseExpression::With { left, right } => {
453            let left_str = expression_to_string(left);
454            let right_str = expression_to_string(right);
455            format!("{} WITH {}", left_str, right_str)
456        }
457    }
458}
459
460fn render_flat_boolean_chain(expr: &LicenseExpression, operator: BooleanOperator) -> String {
461    let mut parts = Vec::new();
462    collect_boolean_chain(expr, operator, &mut parts);
463
464    let separator = match operator {
465        BooleanOperator::And => " AND ",
466        BooleanOperator::Or => " OR ",
467    };
468
469    parts
470        .into_iter()
471        .map(|part| render_boolean_operand(part, operator))
472        .collect::<Vec<_>>()
473        .join(separator)
474}
475
476fn collect_boolean_chain<'a>(
477    expr: &'a LicenseExpression,
478    operator: BooleanOperator,
479    parts: &mut Vec<&'a LicenseExpression>,
480) {
481    match (operator, expr) {
482        (BooleanOperator::And, LicenseExpression::And { left, right })
483        | (BooleanOperator::Or, LicenseExpression::Or { left, right }) => {
484            collect_boolean_chain(left, operator, parts);
485            collect_boolean_chain(right, operator, parts);
486        }
487        _ => parts.push(expr),
488    }
489}
490
491fn render_boolean_operand(expr: &LicenseExpression, parent_operator: BooleanOperator) -> String {
492    match expr {
493        LicenseExpression::License(key) => key.clone(),
494        LicenseExpression::LicenseRef(key) => key.clone(),
495        LicenseExpression::And { .. } => match parent_operator {
496            BooleanOperator::And => expression_to_string(expr),
497            BooleanOperator::Or => format!("({})", expression_to_string(expr)),
498        },
499        LicenseExpression::Or { .. } => match parent_operator {
500            BooleanOperator::Or => expression_to_string(expr),
501            BooleanOperator::And => format!("({})", expression_to_string(expr)),
502        },
503        LicenseExpression::With { left, right } => {
504            let left_str = expression_to_string(left);
505            let right_str = expression_to_string(right);
506            format!("{} WITH {}", left_str, right_str)
507        }
508    }
509}
510
511fn combine_expressions_with(
512    expressions: &[&str],
513    unique: bool,
514    combiner: fn(Vec<LicenseExpression>) -> Option<LicenseExpression>,
515    simplifier: fn(&LicenseExpression) -> LicenseExpression,
516) -> Result<String, ParseError> {
517    if expressions.is_empty() {
518        return Ok(String::new());
519    }
520    if expressions.len() == 1 {
521        let parsed = super::parse::parse_expression(expressions[0])?;
522        return Ok(expression_to_string(&if unique {
523            simplifier(&parsed)
524        } else {
525            parsed
526        }));
527    }
528
529    let parsed_exprs: Vec<LicenseExpression> = expressions
530        .iter()
531        .map(|e| super::parse::parse_expression(e))
532        .collect::<Result<Vec<_>, _>>()?;
533
534    let combined = combiner(parsed_exprs);
535
536    match combined {
537        Some(expr) => {
538            let final_expr = if unique { simplifier(&expr) } else { expr };
539            Ok(expression_to_string(&final_expr))
540        }
541        None => Ok(String::new()),
542    }
543}
544
545/// Combine multiple license expressions with `AND`.
546///
547/// This function parses each expression string, combines them with `AND`, and
548/// optionally deduplicates license keys.
549pub fn combine_expressions_and(expressions: &[&str], unique: bool) -> Result<String, ParseError> {
550    combine_expressions_with(
551        expressions,
552        unique,
553        LicenseExpression::and,
554        simplify_expression,
555    )
556}
557
558/// Combine multiple license expressions with `AND` while preserving the
559/// original boolean structure of distinct operands.
560pub fn combine_expressions_and_preserving_structure(
561    expressions: &[&str],
562    unique: bool,
563) -> Result<String, ParseError> {
564    combine_expressions_with(
565        expressions,
566        unique,
567        LicenseExpression::and,
568        simplify_expression_preserving_structure,
569    )
570}
571
572/// Combine multiple license expressions with `OR`.
573///
574/// This function parses each expression string, combines them with `OR`, and
575/// optionally deduplicates license keys.
576#[allow(dead_code)]
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    #[test]
605    fn test_simplify_expression_no_change() {
606        let expr = super::super::parse::parse_expression("MIT AND Apache-2.0").unwrap();
607        let simplified = simplify_expression(&expr);
608        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
609    }
610
611    #[test]
612    fn test_simplify_expression_with_duplicates() {
613        let expr = super::super::parse::parse_expression("MIT OR MIT").unwrap();
614        let simplified = simplify_expression(&expr);
615        assert_eq!(expression_to_string(&simplified), "mit");
616    }
617
618    #[test]
619    fn test_simplify_expression_preserving_structure_keeps_distinct_nested_operands() {
620        let expr = super::super::parse::parse_expression("mit AND (apache-2.0 OR mit)").unwrap();
621        let simplified = simplify_expression_preserving_structure(&expr);
622        assert_eq!(
623            expression_to_string(&simplified),
624            "mit AND (apache-2.0 OR mit)"
625        );
626    }
627
628    #[test]
629    fn test_simplify_and_duplicates() {
630        let expr = super::super::parse::parse_expression("crapl-0.1 AND crapl-0.1").unwrap();
631        let simplified = simplify_expression(&expr);
632        assert_eq!(expression_to_string(&simplified), "crapl-0.1");
633    }
634
635    #[test]
636    fn test_simplify_or_duplicates() {
637        let expr = super::super::parse::parse_expression("mit OR mit").unwrap();
638        let simplified = simplify_expression(&expr);
639        assert_eq!(expression_to_string(&simplified), "mit");
640    }
641
642    #[test]
643    fn test_combine_expressions_and_preserving_structure_keeps_distinct_nested_operands() {
644        let result =
645            combine_expressions_and_preserving_structure(&["mit", "apache-2.0 OR mit"], true)
646                .unwrap();
647        assert_eq!(result, "mit AND (apache-2.0 OR mit)");
648    }
649
650    #[test]
651    fn test_simplify_preserves_different_licenses() {
652        let expr = super::super::parse::parse_expression("mit AND apache-2.0").unwrap();
653        let simplified = simplify_expression(&expr);
654        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
655    }
656
657    #[test]
658    fn test_simplify_complex_duplicates() {
659        let expr = super::super::parse::parse_expression(
660            "gpl-2.0-plus AND gpl-2.0-plus AND lgpl-2.0-plus",
661        )
662        .unwrap();
663        let simplified = simplify_expression(&expr);
664        assert_eq!(
665            expression_to_string(&simplified),
666            "gpl-2.0-plus AND lgpl-2.0-plus"
667        );
668    }
669
670    #[test]
671    fn test_simplify_three_duplicates() {
672        let expr =
673            super::super::parse::parse_expression("fsf-free AND fsf-free AND fsf-free").unwrap();
674        let simplified = simplify_expression(&expr);
675        assert_eq!(expression_to_string(&simplified), "fsf-free");
676    }
677
678    #[test]
679    fn test_simplify_with_expression_dedup() {
680        let expr = super::super::parse::parse_expression(
681            "gpl-2.0 WITH classpath-exception-2.0 AND gpl-2.0 WITH classpath-exception-2.0",
682        )
683        .unwrap();
684        let simplified = simplify_expression(&expr);
685        assert_eq!(
686            expression_to_string(&simplified),
687            "gpl-2.0 WITH classpath-exception-2.0"
688        );
689    }
690
691    #[test]
692    fn test_simplify_nested_duplicates() {
693        let expr =
694            super::super::parse::parse_expression("(mit AND apache-2.0) OR (mit AND apache-2.0)")
695                .unwrap();
696        let simplified = simplify_expression(&expr);
697        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
698    }
699
700    #[test]
701    fn test_simplify_sorts_operands_canonically() {
702        let expr =
703            super::super::parse::parse_expression("apache-2.0 AND mit AND apache-2.0").unwrap();
704        let simplified = simplify_expression(&expr);
705        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
706    }
707
708    #[test]
709    fn test_simplify_mit_and_mit_and_apache() {
710        let expr = super::super::parse::parse_expression("mit AND mit AND apache-2.0").unwrap();
711        let simplified = simplify_expression(&expr);
712        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
713    }
714
715    #[test]
716    fn test_simplify_and_absorption() {
717        let expr = super::super::parse::parse_expression("mit AND (mit OR apache-2.0)").unwrap();
718        let simplified = simplify_expression(&expr);
719
720        assert_eq!(expression_to_string(&simplified), "mit");
721    }
722
723    #[test]
724    fn test_simplify_or_absorption() {
725        let expr = super::super::parse::parse_expression("mit OR (mit AND apache-2.0)").unwrap();
726        let simplified = simplify_expression(&expr);
727
728        assert_eq!(expression_to_string(&simplified), "mit");
729    }
730
731    #[test]
732    fn test_simplify_or_subsumption() {
733        let expr = super::super::parse::parse_expression(
734            "(mit AND apache-2.0) OR (mit AND apache-2.0 AND bsd-new)",
735        )
736        .unwrap();
737        let simplified = simplify_expression(&expr);
738
739        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
740    }
741
742    #[test]
743    fn test_simplify_and_subsumption() {
744        let expr = super::super::parse::parse_expression(
745            "(mit OR apache-2.0) AND (mit OR apache-2.0 OR bsd-new)",
746        )
747        .unwrap();
748        let simplified = simplify_expression(&expr);
749
750        assert_eq!(expression_to_string(&simplified), "apache-2.0 OR mit");
751    }
752
753    #[test]
754    fn test_simplify_and_keeps_gpl_or_later_with_only() {
755        let expr =
756            super::super::parse::parse_expression("gpl-2.0-or-later AND gpl-2.0-only").unwrap();
757        let simplified = simplify_expression(&expr);
758
759        assert_eq!(
760            expression_to_string(&simplified),
761            "gpl-2.0-only AND gpl-2.0-or-later"
762        );
763    }
764
765    #[test]
766    fn test_expression_to_string_simple() {
767        let expr = LicenseExpression::License("mit".to_string());
768        assert_eq!(expression_to_string(&expr), "mit");
769    }
770
771    #[test]
772    fn test_expression_to_string_and() {
773        let expr = LicenseExpression::And {
774            left: Box::new(LicenseExpression::License("mit".to_string())),
775            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
776        };
777        assert_eq!(expression_to_string(&expr), "mit AND apache-2.0");
778    }
779
780    #[test]
781    fn test_expression_to_string_or() {
782        let expr = LicenseExpression::Or {
783            left: Box::new(LicenseExpression::License("mit".to_string())),
784            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
785        };
786        assert_eq!(expression_to_string(&expr), "mit OR apache-2.0");
787    }
788
789    #[test]
790    fn test_expression_to_string_with() {
791        let expr = LicenseExpression::With {
792            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
793            right: Box::new(LicenseExpression::License(
794                "classpath-exception-2.0".to_string(),
795            )),
796        };
797        assert_eq!(
798            expression_to_string(&expr),
799            "gpl-2.0 WITH classpath-exception-2.0"
800        );
801    }
802
803    #[test]
804    fn test_expression_to_string_licenseref() {
805        let expr = LicenseExpression::LicenseRef("licenseref-scancode-custom".to_string());
806        assert_eq!(expression_to_string(&expr), "licenseref-scancode-custom");
807    }
808
809    #[test]
810    fn test_expression_to_string_or_inside_and() {
811        let or_expr = LicenseExpression::Or {
812            left: Box::new(LicenseExpression::License("mit".to_string())),
813            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
814        };
815        let and_expr = LicenseExpression::And {
816            left: Box::new(or_expr),
817            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
818        };
819        assert_eq!(
820            expression_to_string(&and_expr),
821            "(mit OR apache-2.0) AND gpl-2.0"
822        );
823    }
824
825    #[test]
826    fn test_expression_to_string_and_inside_or() {
827        let and_expr = LicenseExpression::And {
828            left: Box::new(LicenseExpression::License("mit".to_string())),
829            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
830        };
831        let or_expr = LicenseExpression::Or {
832            left: Box::new(and_expr),
833            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
834        };
835        assert_eq!(
836            expression_to_string(&or_expr),
837            "(mit AND apache-2.0) OR gpl-2.0"
838        );
839    }
840
841    #[test]
842    fn test_expression_to_string_with_inside_or() {
843        let with_expr = LicenseExpression::With {
844            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
845            right: Box::new(LicenseExpression::License(
846                "classpath-exception-2.0".to_string(),
847            )),
848        };
849        let or_expr = LicenseExpression::Or {
850            left: Box::new(with_expr),
851            right: Box::new(LicenseExpression::License("mit".to_string())),
852        };
853        assert_eq!(
854            expression_to_string(&or_expr),
855            "gpl-2.0 WITH classpath-exception-2.0 OR mit"
856        );
857    }
858
859    #[test]
860    fn test_expression_to_string_with_inside_and() {
861        let with_expr = LicenseExpression::With {
862            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
863            right: Box::new(LicenseExpression::License(
864                "classpath-exception-2.0".to_string(),
865            )),
866        };
867        let and_expr = LicenseExpression::And {
868            left: Box::new(with_expr),
869            right: Box::new(LicenseExpression::License("mit".to_string())),
870        };
871        assert_eq!(
872            expression_to_string(&and_expr),
873            "gpl-2.0 WITH classpath-exception-2.0 AND mit"
874        );
875    }
876
877    #[test]
878    fn test_expression_to_string_nested_or_flattens_same_operator_grouping() {
879        let or_expr = LicenseExpression::Or {
880            left: Box::new(LicenseExpression::Or {
881                left: Box::new(LicenseExpression::License("mit".to_string())),
882                right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
883            }),
884            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
885        };
886        assert_eq!(
887            expression_to_string(&or_expr),
888            "mit OR apache-2.0 OR gpl-2.0"
889        );
890    }
891
892    #[test]
893    fn test_expression_to_string_nested_and_flattens_same_operator_grouping() {
894        let and_expr = LicenseExpression::And {
895            left: Box::new(LicenseExpression::And {
896                left: Box::new(LicenseExpression::License("mit".to_string())),
897                right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
898            }),
899            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
900        };
901        assert_eq!(
902            expression_to_string(&and_expr),
903            "mit AND apache-2.0 AND gpl-2.0"
904        );
905    }
906
907    #[test]
908    fn test_expression_to_string_roundtrip_or_and() {
909        let input = "(mit OR apache-2.0) AND gpl-2.0";
910        let expr = super::super::parse::parse_expression(input).unwrap();
911        let output = expression_to_string(&expr);
912        assert_eq!(output, "(mit OR apache-2.0) AND gpl-2.0");
913    }
914
915    #[test]
916    fn test_expression_to_string_roundtrip_or_with() {
917        let input = "gpl-2.0 WITH classpath-exception-2.0 OR mit";
918        let expr = super::super::parse::parse_expression(input).unwrap();
919        let output = expression_to_string(&expr);
920        assert_eq!(output, "gpl-2.0 WITH classpath-exception-2.0 OR mit");
921    }
922
923    #[test]
924    fn test_combine_expressions_empty() {
925        let result = combine_expressions_and(&[], true).unwrap();
926        assert_eq!(result, "");
927    }
928
929    #[test]
930    fn test_combine_expressions_single() {
931        let result = combine_expressions_and(&["mit"], true).unwrap();
932        assert_eq!(result, "mit");
933    }
934
935    #[test]
936    fn test_combine_expressions_two_and() {
937        let result = combine_expressions_and(&["mit", "gpl-2.0-plus"], true).unwrap();
938        assert_eq!(result, "gpl-2.0-plus AND mit");
939    }
940
941    #[test]
942    fn test_combine_expressions_two_or() {
943        let result = combine_expressions_or(&["mit", "apache-2.0"], true).unwrap();
944        assert_eq!(result, "apache-2.0 OR mit");
945    }
946
947    #[test]
948    fn test_combine_expressions_multiple_and() {
949        let result = combine_expressions_and(&["mit", "apache-2.0", "gpl-2.0-plus"], true).unwrap();
950        assert_eq!(result, "apache-2.0 AND gpl-2.0-plus AND mit");
951    }
952
953    #[test]
954    fn test_combine_expressions_with_duplicates_unique() {
955        let result = combine_expressions_or(&["mit", "mit", "apache-2.0"], true).unwrap();
956        let expr = super::super::parse::parse_expression(&result).unwrap();
957        let keys = expr.license_keys();
958        assert_eq!(keys.len(), 2);
959        assert!(keys.contains(&"mit".to_string()));
960        assert!(keys.contains(&"apache-2.0".to_string()));
961    }
962
963    #[test]
964    fn test_combine_expressions_with_duplicates_not_unique() {
965        let result = combine_expressions_or(&["mit", "mit", "apache-2.0"], false).unwrap();
966        let expr = super::super::parse::parse_expression(&result).unwrap();
967        assert_eq!(result, "mit OR mit OR apache-2.0");
968        let keys = expr.license_keys();
969        assert_eq!(keys.len(), 2);
970    }
971
972    #[test]
973    fn test_combine_expressions_complex_with_simplification() {
974        let result = combine_expressions_and(&["mit OR apache-2.0", "gpl-2.0-plus"], true).unwrap();
975        assert_eq!(result, "(apache-2.0 OR mit) AND gpl-2.0-plus");
976        let expr = super::super::parse::parse_expression(&result).unwrap();
977        assert!(matches!(expr, LicenseExpression::And { .. }));
978        let keys = expr.license_keys();
979        assert_eq!(keys.len(), 3);
980    }
981
982    #[test]
983    fn test_combine_expressions_parse_error() {
984        let result = combine_expressions_and(&["mit", "@invalid@"], true);
985        assert!(result.is_err());
986    }
987
988    #[test]
989    fn test_combine_expressions_with_existing_and() {
990        let result = combine_expressions_and(&["mit AND apache-2.0", "gpl-2.0"], true).unwrap();
991        assert!(result.contains("mit"));
992        assert!(result.contains("apache-2.0"));
993        assert!(result.contains("gpl-2.0"));
994    }
995
996    #[test]
997    fn test_combine_expressions_with_existing_or() {
998        let result = combine_expressions_or(&["mit OR apache-2.0", "gpl-2.0"], true).unwrap();
999        assert!(result.contains("mit"));
1000        assert!(result.contains("apache-2.0"));
1001        assert!(result.contains("gpl-2.0"));
1002    }
1003
1004    #[test]
1005    fn test_expression_to_string_with_no_outer_parens() {
1006        let with_expr = LicenseExpression::With {
1007            left: Box::new(LicenseExpression::License("gpl-2.0-plus".to_string())),
1008            right: Box::new(LicenseExpression::License(
1009                "classpath-exception-2.0".to_string(),
1010            )),
1011        };
1012        assert_eq!(
1013            expression_to_string(&with_expr),
1014            "gpl-2.0-plus WITH classpath-exception-2.0"
1015        );
1016    }
1017
1018    #[test]
1019    fn test_expression_to_string_with_as_right_operand_of_or() {
1020        let with_expr = LicenseExpression::With {
1021            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
1022            right: Box::new(LicenseExpression::License(
1023                "classpath-exception-2.0".to_string(),
1024            )),
1025        };
1026        let or_expr = LicenseExpression::Or {
1027            left: Box::new(LicenseExpression::License("mit".to_string())),
1028            right: Box::new(with_expr),
1029        };
1030        assert_eq!(
1031            expression_to_string(&or_expr),
1032            "mit OR gpl-2.0 WITH classpath-exception-2.0"
1033        );
1034    }
1035
1036    #[test]
1037    fn test_expression_to_string_with_as_right_operand_of_and() {
1038        let with_expr = LicenseExpression::With {
1039            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
1040            right: Box::new(LicenseExpression::License(
1041                "classpath-exception-2.0".to_string(),
1042            )),
1043        };
1044        let and_expr = LicenseExpression::And {
1045            left: Box::new(LicenseExpression::License("mit".to_string())),
1046            right: Box::new(with_expr),
1047        };
1048        assert_eq!(
1049            expression_to_string(&and_expr),
1050            "mit AND gpl-2.0 WITH classpath-exception-2.0"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_expression_to_string_complex_precedence() {
1056        let input = "mit OR apache-2.0 AND gpl-2.0";
1057        let expr = super::super::parse::parse_expression(input).unwrap();
1058        assert_eq!(
1059            expression_to_string(&expr),
1060            "mit OR (apache-2.0 AND gpl-2.0)"
1061        );
1062    }
1063
1064    #[test]
1065    fn test_expression_to_string_with_no_outer_parens_in_complex_and() {
1066        // WITH has higher precedence than AND
1067        // Parsed as: (bsd-new AND mit) AND (gpl-3.0-plus WITH autoconf-simple-exception)
1068        let input = "bsd-new AND mit AND gpl-3.0-plus WITH autoconf-simple-exception";
1069        let expr = super::super::parse::parse_expression(input).unwrap();
1070        assert_eq!(
1071            expression_to_string(&expr),
1072            "bsd-new AND mit AND gpl-3.0-plus WITH autoconf-simple-exception"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_combine_expressions_and_flattens_reported_redundant_parentheses() {
1078        let result = combine_expressions_and(
1079            &[
1080                "Apache-2.0",
1081                "BSD-3-Clause",
1082                "GPL-2.0-only",
1083                "LicenseRef-scancode-oracle-openjdk-exception-2.0",
1084                "APSL-1.0",
1085                "APSL-2.0",
1086            ],
1087            true,
1088        )
1089        .unwrap();
1090
1091        assert_eq!(
1092            result,
1093            "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"
1094        );
1095    }
1096}
1097
1098#[cfg(test)]
1099mod contains_tests {
1100    use super::*;
1101
1102    #[test]
1103    fn test_basic_containment() {
1104        assert!(licensing_contains("mit", "mit"));
1105        assert!(!licensing_contains("mit", "apache"));
1106    }
1107
1108    #[test]
1109    fn test_or_containment() {
1110        assert!(licensing_contains("mit OR apache", "mit"));
1111        assert!(licensing_contains("mit OR apache", "apache"));
1112        assert!(!licensing_contains("mit OR apache", "gpl"));
1113    }
1114
1115    #[test]
1116    fn test_and_containment() {
1117        assert!(licensing_contains("mit AND apache", "mit"));
1118        assert!(licensing_contains("mit AND apache", "apache"));
1119        assert!(!licensing_contains("mit", "mit AND apache"));
1120    }
1121
1122    #[test]
1123    fn test_expression_subset() {
1124        assert!(licensing_contains(
1125            "mit AND apache AND bsd",
1126            "mit AND apache"
1127        ));
1128        assert!(!licensing_contains(
1129            "mit AND apache",
1130            "mit AND apache AND bsd"
1131        ));
1132        assert!(licensing_contains("mit OR apache OR bsd", "mit OR apache"));
1133        assert!(!licensing_contains("mit OR apache", "mit OR apache OR bsd"));
1134    }
1135
1136    #[test]
1137    fn test_order_independence() {
1138        assert!(licensing_contains("mit AND apache", "apache AND mit"));
1139        assert!(licensing_contains("mit OR apache", "apache OR mit"));
1140    }
1141
1142    #[test]
1143    fn test_plus_suffix_no_containment() {
1144        assert!(!licensing_contains("gpl-2.0-plus", "gpl-2.0"));
1145        assert!(!licensing_contains("gpl-2.0", "gpl-2.0-plus"));
1146    }
1147
1148    #[test]
1149    fn test_with_decomposition() {
1150        assert!(licensing_contains(
1151            "gpl-2.0 WITH classpath-exception",
1152            "gpl-2.0"
1153        ));
1154        assert!(licensing_contains(
1155            "gpl-2.0 WITH classpath-exception",
1156            "classpath-exception"
1157        ));
1158        assert!(!licensing_contains(
1159            "gpl-2.0",
1160            "gpl-2.0 WITH classpath-exception"
1161        ));
1162    }
1163
1164    #[test]
1165    fn test_mixed_operators() {
1166        assert!(!licensing_contains("mit OR apache", "mit AND apache"));
1167        assert!(!licensing_contains("mit AND apache", "mit OR apache"));
1168    }
1169
1170    #[test]
1171    fn test_nested_expressions() {
1172        assert!(!licensing_contains("(mit OR apache) AND bsd", "mit"));
1173        assert!(licensing_contains(
1174            "(mit OR apache) AND bsd",
1175            "mit OR apache"
1176        ));
1177        assert!(licensing_contains("(mit OR apache) AND bsd", "bsd"));
1178    }
1179
1180    #[test]
1181    fn test_empty_expressions() {
1182        assert!(!licensing_contains("", "mit"));
1183        assert!(!licensing_contains("mit", ""));
1184        assert!(!licensing_contains("", ""));
1185        assert!(!licensing_contains("   ", "mit"));
1186    }
1187
1188    #[test]
1189    fn test_invalid_expressions() {
1190        assert!(!licensing_contains("mit AND", "mit"));
1191        assert!(!licensing_contains("mit", "AND apache"));
1192    }
1193}