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