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