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.
377pub fn expression_to_string(expr: &LicenseExpression) -> String {
378    match expr {
379        LicenseExpression::License(key) => key.clone(),
380        LicenseExpression::LicenseRef(key) => key.clone(),
381        LicenseExpression::And { left, right } => {
382            let left_str = expression_to_string_maybe_parens(left, true);
383            let right_str = expression_to_string_maybe_parens(right, true);
384            format!("{} AND {}", left_str, right_str)
385        }
386        LicenseExpression::Or { left, right } => {
387            let left_str = expression_to_string_maybe_parens(left, true);
388            let right_str = expression_to_string_maybe_parens(right, true);
389            format!("{} OR {}", left_str, right_str)
390        }
391        LicenseExpression::With { left, right } => {
392            let left_str = expression_to_string(left);
393            let right_str = expression_to_string(right);
394            format!("{} WITH {}", left_str, right_str)
395        }
396    }
397}
398
399fn expression_to_string_maybe_parens(expr: &LicenseExpression, parent_is_and_or: bool) -> String {
400    match expr {
401        LicenseExpression::License(key) => key.clone(),
402        LicenseExpression::LicenseRef(key) => key.clone(),
403        LicenseExpression::And { .. } | LicenseExpression::Or { .. } => {
404            let result = expression_to_string(expr);
405            if parent_is_and_or {
406                format!("({})", result)
407            } else {
408                result
409            }
410        }
411        LicenseExpression::With { left, right } => {
412            let left_str = expression_to_string(left);
413            let right_str = expression_to_string(right);
414            format!("{} WITH {}", left_str, right_str)
415        }
416    }
417}
418
419fn combine_expressions_with(
420    expressions: &[&str],
421    unique: bool,
422    combiner: fn(Vec<LicenseExpression>) -> Option<LicenseExpression>,
423) -> Result<String, ParseError> {
424    if expressions.is_empty() {
425        return Ok(String::new());
426    }
427    if expressions.len() == 1 {
428        let parsed = super::parse::parse_expression(expressions[0])?;
429        return Ok(expression_to_string(&if unique {
430            simplify_expression(&parsed)
431        } else {
432            parsed
433        }));
434    }
435
436    let parsed_exprs: Vec<LicenseExpression> = expressions
437        .iter()
438        .map(|e| super::parse::parse_expression(e))
439        .collect::<Result<Vec<_>, _>>()?;
440
441    let combined = combiner(parsed_exprs);
442
443    match combined {
444        Some(expr) => {
445            let final_expr = if unique {
446                simplify_expression(&expr)
447            } else {
448                expr
449            };
450            Ok(expression_to_string(&final_expr))
451        }
452        None => Ok(String::new()),
453    }
454}
455
456/// Combine multiple license expressions with `AND`.
457///
458/// This function parses each expression string, combines them with `AND`, and
459/// optionally deduplicates license keys.
460pub fn combine_expressions_and(expressions: &[&str], unique: bool) -> Result<String, ParseError> {
461    combine_expressions_with(expressions, unique, LicenseExpression::and)
462}
463
464/// Combine multiple license expressions with `OR`.
465///
466/// This function parses each expression string, combines them with `OR`, and
467/// optionally deduplicates license keys.
468// Kept for future parity work where production code needs to combine
469// expressions with OR, especially beyond the current license-detection path.
470// See docs/license-detection/GAPS.md#expression-key-set-features.
471#[allow(dead_code)]
472pub fn combine_expressions_or(expressions: &[&str], unique: bool) -> Result<String, ParseError> {
473    combine_expressions_with(expressions, unique, LicenseExpression::or)
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn test_simplify_expression_no_change() {
482        let expr = super::super::parse::parse_expression("MIT AND Apache-2.0").unwrap();
483        let simplified = simplify_expression(&expr);
484        assert_eq!(expression_to_string(&simplified), "mit AND apache-2.0");
485    }
486
487    #[test]
488    fn test_simplify_expression_with_duplicates() {
489        let expr = super::super::parse::parse_expression("MIT OR MIT").unwrap();
490        let simplified = simplify_expression(&expr);
491        assert_eq!(expression_to_string(&simplified), "mit");
492    }
493
494    #[test]
495    fn test_simplify_and_duplicates() {
496        let expr = super::super::parse::parse_expression("crapl-0.1 AND crapl-0.1").unwrap();
497        let simplified = simplify_expression(&expr);
498        assert_eq!(expression_to_string(&simplified), "crapl-0.1");
499    }
500
501    #[test]
502    fn test_simplify_or_duplicates() {
503        let expr = super::super::parse::parse_expression("mit OR mit").unwrap();
504        let simplified = simplify_expression(&expr);
505        assert_eq!(expression_to_string(&simplified), "mit");
506    }
507
508    #[test]
509    fn test_simplify_preserves_different_licenses() {
510        let expr = super::super::parse::parse_expression("mit AND apache-2.0").unwrap();
511        let simplified = simplify_expression(&expr);
512        assert_eq!(expression_to_string(&simplified), "mit AND apache-2.0");
513    }
514
515    #[test]
516    fn test_simplify_complex_duplicates() {
517        let expr = super::super::parse::parse_expression(
518            "gpl-2.0-plus AND gpl-2.0-plus AND lgpl-2.0-plus",
519        )
520        .unwrap();
521        let simplified = simplify_expression(&expr);
522        assert_eq!(
523            expression_to_string(&simplified),
524            "gpl-2.0-plus AND lgpl-2.0-plus"
525        );
526    }
527
528    #[test]
529    fn test_simplify_three_duplicates() {
530        let expr =
531            super::super::parse::parse_expression("fsf-free AND fsf-free AND fsf-free").unwrap();
532        let simplified = simplify_expression(&expr);
533        assert_eq!(expression_to_string(&simplified), "fsf-free");
534    }
535
536    #[test]
537    fn test_simplify_with_expression_dedup() {
538        let expr = super::super::parse::parse_expression(
539            "gpl-2.0 WITH classpath-exception-2.0 AND gpl-2.0 WITH classpath-exception-2.0",
540        )
541        .unwrap();
542        let simplified = simplify_expression(&expr);
543        assert_eq!(
544            expression_to_string(&simplified),
545            "gpl-2.0 WITH classpath-exception-2.0"
546        );
547    }
548
549    #[test]
550    fn test_simplify_nested_duplicates() {
551        let expr =
552            super::super::parse::parse_expression("(mit AND apache-2.0) OR (mit AND apache-2.0)")
553                .unwrap();
554        let simplified = simplify_expression(&expr);
555        assert_eq!(expression_to_string(&simplified), "mit AND apache-2.0");
556    }
557
558    #[test]
559    fn test_simplify_preserves_order() {
560        let expr =
561            super::super::parse::parse_expression("apache-2.0 AND mit AND apache-2.0").unwrap();
562        let simplified = simplify_expression(&expr);
563        assert_eq!(expression_to_string(&simplified), "apache-2.0 AND mit");
564    }
565
566    #[test]
567    fn test_simplify_mit_and_mit_and_apache() {
568        let expr = super::super::parse::parse_expression("mit AND mit AND apache-2.0").unwrap();
569        let simplified = simplify_expression(&expr);
570        assert_eq!(expression_to_string(&simplified), "mit AND apache-2.0");
571    }
572
573    #[test]
574    fn test_simplify_and_absorption() {
575        let expr = super::super::parse::parse_expression("mit AND (mit OR apache-2.0)").unwrap();
576        let simplified = simplify_expression(&expr);
577
578        assert_eq!(expression_to_string(&simplified), "mit");
579    }
580
581    #[test]
582    fn test_simplify_or_absorption() {
583        let expr = super::super::parse::parse_expression("mit OR (mit AND apache-2.0)").unwrap();
584        let simplified = simplify_expression(&expr);
585
586        assert_eq!(expression_to_string(&simplified), "mit");
587    }
588
589    #[test]
590    fn test_simplify_or_subsumption() {
591        let expr = super::super::parse::parse_expression(
592            "(mit AND apache-2.0) OR (mit AND apache-2.0 AND bsd-new)",
593        )
594        .unwrap();
595        let simplified = simplify_expression(&expr);
596
597        assert_eq!(expression_to_string(&simplified), "mit AND apache-2.0");
598    }
599
600    #[test]
601    fn test_simplify_and_subsumption() {
602        let expr = super::super::parse::parse_expression(
603            "(mit OR apache-2.0) AND (mit OR apache-2.0 OR bsd-new)",
604        )
605        .unwrap();
606        let simplified = simplify_expression(&expr);
607
608        assert_eq!(expression_to_string(&simplified), "mit OR apache-2.0");
609    }
610
611    #[test]
612    fn test_expression_to_string_simple() {
613        let expr = LicenseExpression::License("mit".to_string());
614        assert_eq!(expression_to_string(&expr), "mit");
615    }
616
617    #[test]
618    fn test_expression_to_string_and() {
619        let expr = LicenseExpression::And {
620            left: Box::new(LicenseExpression::License("mit".to_string())),
621            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
622        };
623        assert_eq!(expression_to_string(&expr), "mit AND apache-2.0");
624    }
625
626    #[test]
627    fn test_expression_to_string_or() {
628        let expr = LicenseExpression::Or {
629            left: Box::new(LicenseExpression::License("mit".to_string())),
630            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
631        };
632        assert_eq!(expression_to_string(&expr), "mit OR apache-2.0");
633    }
634
635    #[test]
636    fn test_expression_to_string_with() {
637        let expr = LicenseExpression::With {
638            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
639            right: Box::new(LicenseExpression::License(
640                "classpath-exception-2.0".to_string(),
641            )),
642        };
643        assert_eq!(
644            expression_to_string(&expr),
645            "gpl-2.0 WITH classpath-exception-2.0"
646        );
647    }
648
649    #[test]
650    fn test_expression_to_string_licenseref() {
651        let expr = LicenseExpression::LicenseRef("licenseref-scancode-custom".to_string());
652        assert_eq!(expression_to_string(&expr), "licenseref-scancode-custom");
653    }
654
655    #[test]
656    fn test_expression_to_string_or_inside_and() {
657        let or_expr = LicenseExpression::Or {
658            left: Box::new(LicenseExpression::License("mit".to_string())),
659            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
660        };
661        let and_expr = LicenseExpression::And {
662            left: Box::new(or_expr),
663            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
664        };
665        assert_eq!(
666            expression_to_string(&and_expr),
667            "(mit OR apache-2.0) AND gpl-2.0"
668        );
669    }
670
671    #[test]
672    fn test_expression_to_string_and_inside_or() {
673        let and_expr = LicenseExpression::And {
674            left: Box::new(LicenseExpression::License("mit".to_string())),
675            right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
676        };
677        let or_expr = LicenseExpression::Or {
678            left: Box::new(and_expr),
679            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
680        };
681        assert_eq!(
682            expression_to_string(&or_expr),
683            "(mit AND apache-2.0) OR gpl-2.0"
684        );
685    }
686
687    #[test]
688    fn test_expression_to_string_with_inside_or() {
689        let with_expr = LicenseExpression::With {
690            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
691            right: Box::new(LicenseExpression::License(
692                "classpath-exception-2.0".to_string(),
693            )),
694        };
695        let or_expr = LicenseExpression::Or {
696            left: Box::new(with_expr),
697            right: Box::new(LicenseExpression::License("mit".to_string())),
698        };
699        assert_eq!(
700            expression_to_string(&or_expr),
701            "gpl-2.0 WITH classpath-exception-2.0 OR mit"
702        );
703    }
704
705    #[test]
706    fn test_expression_to_string_with_inside_and() {
707        let with_expr = LicenseExpression::With {
708            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
709            right: Box::new(LicenseExpression::License(
710                "classpath-exception-2.0".to_string(),
711            )),
712        };
713        let and_expr = LicenseExpression::And {
714            left: Box::new(with_expr),
715            right: Box::new(LicenseExpression::License("mit".to_string())),
716        };
717        assert_eq!(
718            expression_to_string(&and_expr),
719            "gpl-2.0 WITH classpath-exception-2.0 AND mit"
720        );
721    }
722
723    #[test]
724    fn test_expression_to_string_nested_or_preserves_grouping() {
725        // Manually constructed nested OR: (mit OR apache-2.0) OR gpl-2.0
726        // When nested OR is constructed manually, it renders with parens
727        // This matches Python license-expression behavior
728        let or_expr = LicenseExpression::Or {
729            left: Box::new(LicenseExpression::Or {
730                left: Box::new(LicenseExpression::License("mit".to_string())),
731                right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
732            }),
733            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
734        };
735        assert_eq!(
736            expression_to_string(&or_expr),
737            "(mit OR apache-2.0) OR gpl-2.0"
738        );
739    }
740
741    #[test]
742    fn test_expression_to_string_nested_and_preserves_grouping() {
743        // Manually constructed nested AND: (mit AND apache-2.0) AND gpl-2.0
744        // When nested AND is constructed manually, it renders with parens
745        // This matches Python license-expression behavior
746        let and_expr = LicenseExpression::And {
747            left: Box::new(LicenseExpression::And {
748                left: Box::new(LicenseExpression::License("mit".to_string())),
749                right: Box::new(LicenseExpression::License("apache-2.0".to_string())),
750            }),
751            right: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
752        };
753        assert_eq!(
754            expression_to_string(&and_expr),
755            "(mit AND apache-2.0) AND gpl-2.0"
756        );
757    }
758
759    #[test]
760    fn test_expression_to_string_roundtrip_or_and() {
761        let input = "(mit OR apache-2.0) AND gpl-2.0";
762        let expr = super::super::parse::parse_expression(input).unwrap();
763        let output = expression_to_string(&expr);
764        assert_eq!(output, "(mit OR apache-2.0) AND gpl-2.0");
765    }
766
767    #[test]
768    fn test_expression_to_string_roundtrip_or_with() {
769        let input = "gpl-2.0 WITH classpath-exception-2.0 OR mit";
770        let expr = super::super::parse::parse_expression(input).unwrap();
771        let output = expression_to_string(&expr);
772        assert_eq!(output, "gpl-2.0 WITH classpath-exception-2.0 OR mit");
773    }
774
775    #[test]
776    fn test_combine_expressions_empty() {
777        let result = combine_expressions_and(&[], true).unwrap();
778        assert_eq!(result, "");
779    }
780
781    #[test]
782    fn test_combine_expressions_single() {
783        let result = combine_expressions_and(&["mit"], true).unwrap();
784        assert_eq!(result, "mit");
785    }
786
787    #[test]
788    fn test_combine_expressions_two_and() {
789        let result = combine_expressions_and(&["mit", "gpl-2.0-plus"], true).unwrap();
790        assert_eq!(result, "mit AND gpl-2.0-plus");
791    }
792
793    #[test]
794    fn test_combine_expressions_two_or() {
795        let result = combine_expressions_or(&["mit", "apache-2.0"], true).unwrap();
796        assert_eq!(result, "mit OR apache-2.0");
797    }
798
799    #[test]
800    fn test_combine_expressions_multiple_and() {
801        let result = combine_expressions_and(&["mit", "apache-2.0", "gpl-2.0-plus"], true).unwrap();
802        assert!(result.contains("mit"));
803        assert!(result.contains("apache-2.0"));
804        assert!(result.contains("gpl-2.0-plus"));
805        assert_eq!(result.matches("AND").count(), 2);
806    }
807
808    #[test]
809    fn test_combine_expressions_with_duplicates_unique() {
810        let result = combine_expressions_or(&["mit", "mit", "apache-2.0"], true).unwrap();
811        let expr = super::super::parse::parse_expression(&result).unwrap();
812        let keys = expr.license_keys();
813        assert_eq!(keys.len(), 2);
814        assert!(keys.contains(&"mit".to_string()));
815        assert!(keys.contains(&"apache-2.0".to_string()));
816    }
817
818    #[test]
819    fn test_combine_expressions_with_duplicates_not_unique() {
820        let result = combine_expressions_or(&["mit", "mit", "apache-2.0"], false).unwrap();
821        let expr = super::super::parse::parse_expression(&result).unwrap();
822        // combine_expressions creates nested structure, so we get parens
823        assert_eq!(result, "(mit OR mit) OR apache-2.0");
824        let keys = expr.license_keys();
825        assert_eq!(keys.len(), 2);
826    }
827
828    #[test]
829    fn test_combine_expressions_complex_with_simplification() {
830        let result = combine_expressions_and(&["mit OR apache-2.0", "gpl-2.0-plus"], true).unwrap();
831        assert_eq!(result, "(mit OR apache-2.0) AND gpl-2.0-plus");
832        let expr = super::super::parse::parse_expression(&result).unwrap();
833        assert!(matches!(expr, LicenseExpression::And { .. }));
834        let keys = expr.license_keys();
835        assert_eq!(keys.len(), 3);
836    }
837
838    #[test]
839    fn test_combine_expressions_parse_error() {
840        let result = combine_expressions_and(&["mit", "@invalid@"], true);
841        assert!(result.is_err());
842    }
843
844    #[test]
845    fn test_combine_expressions_with_existing_and() {
846        let result = combine_expressions_and(&["mit AND apache-2.0", "gpl-2.0"], true).unwrap();
847        assert!(result.contains("mit"));
848        assert!(result.contains("apache-2.0"));
849        assert!(result.contains("gpl-2.0"));
850    }
851
852    #[test]
853    fn test_combine_expressions_with_existing_or() {
854        let result = combine_expressions_or(&["mit OR apache-2.0", "gpl-2.0"], true).unwrap();
855        assert!(result.contains("mit"));
856        assert!(result.contains("apache-2.0"));
857        assert!(result.contains("gpl-2.0"));
858    }
859
860    #[test]
861    fn test_expression_to_string_with_no_outer_parens() {
862        let with_expr = LicenseExpression::With {
863            left: Box::new(LicenseExpression::License("gpl-2.0-plus".to_string())),
864            right: Box::new(LicenseExpression::License(
865                "classpath-exception-2.0".to_string(),
866            )),
867        };
868        assert_eq!(
869            expression_to_string(&with_expr),
870            "gpl-2.0-plus WITH classpath-exception-2.0"
871        );
872    }
873
874    #[test]
875    fn test_expression_to_string_with_as_right_operand_of_or() {
876        let with_expr = LicenseExpression::With {
877            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
878            right: Box::new(LicenseExpression::License(
879                "classpath-exception-2.0".to_string(),
880            )),
881        };
882        let or_expr = LicenseExpression::Or {
883            left: Box::new(LicenseExpression::License("mit".to_string())),
884            right: Box::new(with_expr),
885        };
886        assert_eq!(
887            expression_to_string(&or_expr),
888            "mit OR gpl-2.0 WITH classpath-exception-2.0"
889        );
890    }
891
892    #[test]
893    fn test_expression_to_string_with_as_right_operand_of_and() {
894        let with_expr = LicenseExpression::With {
895            left: Box::new(LicenseExpression::License("gpl-2.0".to_string())),
896            right: Box::new(LicenseExpression::License(
897                "classpath-exception-2.0".to_string(),
898            )),
899        };
900        let and_expr = LicenseExpression::And {
901            left: Box::new(LicenseExpression::License("mit".to_string())),
902            right: Box::new(with_expr),
903        };
904        assert_eq!(
905            expression_to_string(&and_expr),
906            "mit AND gpl-2.0 WITH classpath-exception-2.0"
907        );
908    }
909
910    #[test]
911    fn test_expression_to_string_complex_precedence() {
912        let input = "mit OR apache-2.0 AND gpl-2.0";
913        let expr = super::super::parse::parse_expression(input).unwrap();
914        assert_eq!(
915            expression_to_string(&expr),
916            "mit OR (apache-2.0 AND gpl-2.0)"
917        );
918    }
919
920    #[test]
921    fn test_expression_to_string_with_no_outer_parens_in_complex_and() {
922        // WITH has higher precedence than AND
923        // Parsed as: (bsd-new AND mit) AND (gpl-3.0-plus WITH autoconf-simple-exception)
924        // When rendered with our nested structure, we get parens on the left
925        let input = "bsd-new AND mit AND gpl-3.0-plus WITH autoconf-simple-exception";
926        let expr = super::super::parse::parse_expression(input).unwrap();
927        // Our parser creates nested AND structure, so we get parens
928        assert_eq!(
929            expression_to_string(&expr),
930            "(bsd-new AND mit) AND gpl-3.0-plus WITH autoconf-simple-exception"
931        );
932    }
933}
934
935#[cfg(test)]
936mod contains_tests {
937    use super::*;
938
939    #[test]
940    fn test_basic_containment() {
941        assert!(licensing_contains("mit", "mit"));
942        assert!(!licensing_contains("mit", "apache"));
943    }
944
945    #[test]
946    fn test_or_containment() {
947        assert!(licensing_contains("mit OR apache", "mit"));
948        assert!(licensing_contains("mit OR apache", "apache"));
949        assert!(!licensing_contains("mit OR apache", "gpl"));
950    }
951
952    #[test]
953    fn test_and_containment() {
954        assert!(licensing_contains("mit AND apache", "mit"));
955        assert!(licensing_contains("mit AND apache", "apache"));
956        assert!(!licensing_contains("mit", "mit AND apache"));
957    }
958
959    #[test]
960    fn test_expression_subset() {
961        assert!(licensing_contains(
962            "mit AND apache AND bsd",
963            "mit AND apache"
964        ));
965        assert!(!licensing_contains(
966            "mit AND apache",
967            "mit AND apache AND bsd"
968        ));
969        assert!(licensing_contains("mit OR apache OR bsd", "mit OR apache"));
970        assert!(!licensing_contains("mit OR apache", "mit OR apache OR bsd"));
971    }
972
973    #[test]
974    fn test_order_independence() {
975        assert!(licensing_contains("mit AND apache", "apache AND mit"));
976        assert!(licensing_contains("mit OR apache", "apache OR mit"));
977    }
978
979    #[test]
980    fn test_plus_suffix_no_containment() {
981        assert!(!licensing_contains("gpl-2.0-plus", "gpl-2.0"));
982        assert!(!licensing_contains("gpl-2.0", "gpl-2.0-plus"));
983    }
984
985    #[test]
986    fn test_with_decomposition() {
987        assert!(licensing_contains(
988            "gpl-2.0 WITH classpath-exception",
989            "gpl-2.0"
990        ));
991        assert!(licensing_contains(
992            "gpl-2.0 WITH classpath-exception",
993            "classpath-exception"
994        ));
995        assert!(!licensing_contains(
996            "gpl-2.0",
997            "gpl-2.0 WITH classpath-exception"
998        ));
999    }
1000
1001    #[test]
1002    fn test_mixed_operators() {
1003        assert!(!licensing_contains("mit OR apache", "mit AND apache"));
1004        assert!(!licensing_contains("mit AND apache", "mit OR apache"));
1005    }
1006
1007    #[test]
1008    fn test_nested_expressions() {
1009        assert!(!licensing_contains("(mit OR apache) AND bsd", "mit"));
1010        assert!(licensing_contains(
1011            "(mit OR apache) AND bsd",
1012            "mit OR apache"
1013        ));
1014        assert!(licensing_contains("(mit OR apache) AND bsd", "bsd"));
1015    }
1016
1017    #[test]
1018    fn test_empty_expressions() {
1019        assert!(!licensing_contains("", "mit"));
1020        assert!(!licensing_contains("mit", ""));
1021        assert!(!licensing_contains("", ""));
1022        assert!(!licensing_contains("   ", "mit"));
1023    }
1024
1025    #[test]
1026    fn test_invalid_expressions() {
1027        assert!(!licensing_contains("mit AND", "mit"));
1028        assert!(!licensing_contains("mit", "AND apache"));
1029    }
1030}