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