Skip to main content

provenant/license_detection/expression/
simplify.rs

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