license_detection.rs

  1use std::{
  2    collections::BTreeSet,
  3    fmt::{Display, Formatter},
  4    path::{Path, PathBuf},
  5    sync::{Arc, LazyLock},
  6};
  7
  8use fs::Fs;
  9use futures::StreamExt as _;
 10use gpui::{App, AppContext as _, Entity, Subscription, Task};
 11use itertools::Itertools;
 12use postage::watch;
 13use project::Worktree;
 14use regex::Regex;
 15use strum::VariantArray;
 16use util::ResultExt as _;
 17use worktree::ChildEntriesOptions;
 18
 19/// Matches the most common license locations, with US and UK English spelling.
 20static LICENSE_FILE_NAME_REGEX: LazyLock<regex::bytes::Regex> = LazyLock::new(|| {
 21    regex::bytes::RegexBuilder::new(
 22        "^ \
 23        (?: license | licence)? \
 24        (?: [\\-._]? \
 25            (?: apache (?: [\\-._] (?: 2.0 | 2 ))? | \
 26                0? bsd (?: [\\-._] [0123])? (?: [\\-._] clause)? | \
 27                isc | \
 28                mit | \
 29                upl | \
 30                zlib))? \
 31        (?: [\\-._]? (?: license | licence))? \
 32        (?: \\.txt | \\.md)? \
 33        $",
 34    )
 35    .ignore_whitespace(true)
 36    .case_insensitive(true)
 37    .build()
 38    .unwrap()
 39});
 40
 41#[derive(Debug, Clone, Copy, Eq, Ord, PartialOrd, PartialEq, VariantArray)]
 42pub enum OpenSourceLicense {
 43    Apache2_0,
 44    BSDZero,
 45    BSD,
 46    ISC,
 47    MIT,
 48    UPL1_0,
 49    Zlib,
 50}
 51
 52impl Display for OpenSourceLicense {
 53    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
 54        write!(f, "{}", self.spdx_identifier())
 55    }
 56}
 57
 58impl OpenSourceLicense {
 59    /// These are SPDX identifiers for the licenses, except for BSD, where the variants are not
 60    /// distinguished.
 61    pub fn spdx_identifier(&self) -> &'static str {
 62        match self {
 63            OpenSourceLicense::Apache2_0 => "apache-2.0",
 64            OpenSourceLicense::BSDZero => "0bsd",
 65            OpenSourceLicense::BSD => "bsd",
 66            OpenSourceLicense::ISC => "isc",
 67            OpenSourceLicense::MIT => "mit",
 68            OpenSourceLicense::UPL1_0 => "upl-1.0",
 69            OpenSourceLicense::Zlib => "zlib",
 70        }
 71    }
 72
 73    /// Regexes to match the license text. These regexes are expected to match the entire file. Also
 74    /// note that `canonicalize_license_text` removes everything but alphanumeric ascii characters.
 75    pub fn regex(&self) -> &'static str {
 76        match self {
 77            OpenSourceLicense::Apache2_0 => include_str!("../license_regexes/apache-2.0.regex"),
 78            OpenSourceLicense::BSDZero => include_str!("../license_regexes/0bsd.regex"),
 79            OpenSourceLicense::BSD => include_str!("../license_regexes/bsd.regex"),
 80            OpenSourceLicense::ISC => include_str!("../license_regexes/isc.regex"),
 81            OpenSourceLicense::MIT => include_str!("../license_regexes/mit.regex"),
 82            OpenSourceLicense::UPL1_0 => include_str!("../license_regexes/upl-1.0.regex"),
 83            OpenSourceLicense::Zlib => include_str!("../license_regexes/zlib.regex"),
 84        }
 85    }
 86}
 87
 88fn detect_license(license: &str) -> Option<OpenSourceLicense> {
 89    static LICENSE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
 90        let mut regex_string = String::new();
 91        let mut is_first = true;
 92        for license in OpenSourceLicense::VARIANTS {
 93            if is_first {
 94                regex_string.push_str("^(?:(");
 95                is_first = false;
 96            } else {
 97                regex_string.push_str(")|(");
 98            }
 99            regex_string.push_str(&canonicalize_license_regex(license.regex()));
100        }
101        regex_string.push_str("))$");
102        let regex = Regex::new(&regex_string).unwrap();
103        assert_eq!(regex.captures_len(), OpenSourceLicense::VARIANTS.len() + 1);
104        regex
105    });
106
107    LICENSE_REGEX
108        .captures(&canonicalize_license_text(license))
109        .and_then(|captures| {
110            let license = OpenSourceLicense::VARIANTS
111                .iter()
112                .enumerate()
113                .find(|(index, _)| captures.get(index + 1).is_some())
114                .map(|(_, license)| *license);
115            if license.is_none() {
116                log::error!("bug: open source license regex matched without any capture groups");
117            }
118            license
119        })
120}
121
122/// Canonicalizes the whitespace of license text.
123fn canonicalize_license_regex(license: &str) -> String {
124    license
125        .split_ascii_whitespace()
126        .join(" ")
127        .to_ascii_lowercase()
128}
129
130/// Canonicalizes the whitespace of license text.
131fn canonicalize_license_text(license: &str) -> String {
132    license
133        .chars()
134        .filter(|c| c.is_ascii_alphanumeric() || c.is_ascii_whitespace())
135        .map(|c| c.to_ascii_lowercase())
136        .collect::<String>()
137        .split_ascii_whitespace()
138        .join(" ")
139}
140
141pub enum LicenseDetectionWatcher {
142    Local {
143        is_open_source_rx: watch::Receiver<bool>,
144        _is_open_source_task: Task<()>,
145        _worktree_subscription: Subscription,
146    },
147    SingleFile,
148    Remote,
149}
150
151impl LicenseDetectionWatcher {
152    pub fn new(worktree: &Entity<Worktree>, cx: &mut App) -> Self {
153        let worktree_ref = worktree.read(cx);
154        if worktree_ref.is_single_file() {
155            return Self::SingleFile;
156        }
157
158        let (files_to_check_tx, mut files_to_check_rx) = futures::channel::mpsc::unbounded();
159
160        let Worktree::Local(local_worktree) = worktree_ref else {
161            return Self::Remote;
162        };
163        let fs = local_worktree.fs().clone();
164        let worktree_abs_path = local_worktree.abs_path().clone();
165
166        let options = ChildEntriesOptions {
167            include_files: true,
168            include_dirs: false,
169            include_ignored: true,
170        };
171        for top_file in local_worktree.child_entries_with_options(Path::new(""), options) {
172            let path_bytes = top_file.path.as_os_str().as_encoded_bytes();
173            if top_file.is_created() && LICENSE_FILE_NAME_REGEX.is_match(path_bytes) {
174                let rel_path = top_file.path.clone();
175                files_to_check_tx.unbounded_send(rel_path).ok();
176            }
177        }
178
179        let _worktree_subscription =
180            cx.subscribe(worktree, move |_worktree, event, _cx| match event {
181                worktree::Event::UpdatedEntries(updated_entries) => {
182                    for updated_entry in updated_entries.iter() {
183                        let rel_path = &updated_entry.0;
184                        let path_bytes = rel_path.as_os_str().as_encoded_bytes();
185                        if LICENSE_FILE_NAME_REGEX.is_match(path_bytes) {
186                            files_to_check_tx.unbounded_send(rel_path.clone()).ok();
187                        }
188                    }
189                }
190                worktree::Event::DeletedEntry(_) | worktree::Event::UpdatedGitRepositories(_) => {}
191            });
192
193        let (mut is_open_source_tx, is_open_source_rx) = watch::channel_with::<bool>(false);
194
195        let _is_open_source_task = cx.background_spawn(async move {
196            let mut eligible_licenses = BTreeSet::new();
197            while let Some(rel_path) = files_to_check_rx.next().await {
198                let abs_path = worktree_abs_path.join(&rel_path);
199                let was_open_source = !eligible_licenses.is_empty();
200                if Self::is_path_eligible(&fs, abs_path).await.unwrap_or(false) {
201                    eligible_licenses.insert(rel_path);
202                } else {
203                    eligible_licenses.remove(&rel_path);
204                }
205                let is_open_source = !eligible_licenses.is_empty();
206                if is_open_source != was_open_source {
207                    *is_open_source_tx.borrow_mut() = is_open_source;
208                }
209            }
210        });
211
212        Self::Local {
213            is_open_source_rx,
214            _is_open_source_task,
215            _worktree_subscription,
216        }
217    }
218
219    async fn is_path_eligible(fs: &Arc<dyn Fs>, abs_path: PathBuf) -> Option<bool> {
220        log::debug!("checking if `{abs_path:?}` is an open source license");
221        // Resolve symlinks so that the file size from metadata is correct.
222        let Some(abs_path) = fs.canonicalize(&abs_path).await.ok() else {
223            log::debug!(
224                "`{abs_path:?}` license file probably deleted (error canonicalizing the path)"
225            );
226            return None;
227        };
228        let metadata = fs.metadata(&abs_path).await.log_err()??;
229        // If the license file is >32kb it's unlikely to legitimately match any eligible license.
230        if metadata.len > 32768 {
231            return None;
232        }
233        let text = fs.load(&abs_path).await.log_err()?;
234        let is_eligible = detect_license(&text).is_some();
235        if is_eligible {
236            log::debug!(
237                "`{abs_path:?}` matches a license that is eligible for data collection (if enabled)"
238            );
239        } else {
240            log::debug!(
241                "`{abs_path:?}` does not match a license that is eligible for data collection"
242            );
243        }
244        Some(is_eligible)
245    }
246
247    /// Answers false until we find out it's open source
248    pub fn is_project_open_source(&self) -> bool {
249        match self {
250            Self::Local {
251                is_open_source_rx, ..
252            } => *is_open_source_rx.borrow(),
253            Self::SingleFile | Self::Remote => false,
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260
261    use fs::FakeFs;
262    use gpui::TestAppContext;
263    use serde_json::json;
264    use settings::{Settings as _, SettingsStore};
265    use unindent::unindent;
266    use worktree::WorktreeSettings;
267
268    use super::*;
269
270    const APACHE_2_0_TXT: &str = include_str!("../license_examples/apache-2.0-ex0.txt");
271    const ISC_TXT: &str = include_str!("../license_examples/isc.txt");
272    const MIT_TXT: &str = include_str!("../license_examples/mit-ex0.txt");
273    const UPL_1_0_TXT: &str = include_str!("../license_examples/upl-1.0.txt");
274    const BSD_0_TXT: &str = include_str!("../license_examples/0bsd.txt");
275
276    #[track_caller]
277    fn assert_matches_license(text: &str, license: OpenSourceLicense) {
278        if detect_license(text) != Some(license) {
279            let license_regex_text = canonicalize_license_regex(license.regex());
280            let license_regex = Regex::new(&format!("^{}$", license_regex_text)).unwrap();
281            let text = canonicalize_license_text(text);
282            let matched_regex = license_regex.is_match(&text);
283            if matched_regex {
284                panic!(
285                    "The following text matches the individual regex for {}, \
286                    but not the combined one:\n```license-text\n{}\n```\n",
287                    license, text
288                );
289            } else {
290                panic!(
291                    "The following text doesn't match the regex for {}:\n\
292                    ```license-text\n{}\n```\n\n```regex\n{}\n```\n",
293                    license, text, license_regex_text
294                );
295            }
296        }
297    }
298
299    /*
300    // Uncomment this and run with `cargo test -p zeta -- --no-capture &> licenses-output` to
301    // traverse your entire home directory and run license detection on every file that has a
302    // license-like name.
303    #[test]
304    fn test_check_all_licenses_in_home_dir() {
305        let mut detected = Vec::new();
306        let mut unrecognized = Vec::new();
307        let mut walked_entries = 0;
308        let homedir = std::env::home_dir().unwrap();
309        for entry in walkdir::WalkDir::new(&homedir) {
310            walked_entries += 1;
311            if walked_entries % 10000 == 0 {
312                println!(
313                    "So far visited {} files in {}",
314                    walked_entries,
315                    homedir.display()
316                );
317            }
318            let Ok(entry) = entry else {
319                continue;
320            };
321            if !LICENSE_FILE_NAME_REGEX.is_match(entry.file_name().as_encoded_bytes()) {
322                continue;
323            }
324            let Ok(contents) = std::fs::read_to_string(entry.path()) else {
325                continue;
326            };
327            let path_string = entry.path().to_string_lossy().to_string();
328            match detect_license(&contents) {
329                Some(license) => detected.push((license, path_string)),
330                None => unrecognized.push(path_string),
331            }
332        }
333        println!("\nDetected licenses:\n");
334        detected.sort();
335        for (license, path) in &detected {
336            println!("{}: {}", license.spdx_identifier(), path);
337        }
338        println!("\nUnrecognized licenses:\n");
339        for path in &unrecognized {
340            println!("{}", path);
341        }
342        panic!(
343            "{} licenses detected, {} unrecognized",
344            detected.len(),
345            unrecognized.len()
346        );
347        println!("This line has a warning to make sure this test is always commented out");
348    }
349    */
350
351    #[test]
352    fn test_no_unicode_in_regexes() {
353        for license in OpenSourceLicense::VARIANTS {
354            assert!(
355                !license.regex().contains(|c: char| !c.is_ascii()),
356                "{}.regex contains unicode",
357                license.spdx_identifier()
358            );
359        }
360    }
361
362    #[test]
363    fn test_apache_positive_detection() {
364        assert_matches_license(APACHE_2_0_TXT, OpenSourceLicense::Apache2_0);
365
366        let license_with_appendix = format!(
367            r#"{APACHE_2_0_TXT}
368
369            END OF TERMS AND CONDITIONS
370
371            APPENDIX: How to apply the Apache License to your work.
372
373                To apply the Apache License to your work, attach the following
374                boilerplate notice, with the fields enclosed by brackets "[]"
375                replaced with your own identifying information. (Don't include
376                the brackets!)  The text should be enclosed in the appropriate
377                comment syntax for the file format. We also recommend that a
378                file or class name and description of purpose be included on the
379                same "printed page" as the copyright notice for easier
380                identification within third-party archives.
381
382            Copyright [yyyy] [name of copyright owner]
383
384            Licensed under the Apache License, Version 2.0 (the "License");
385            you may not use this file except in compliance with the License.
386            You may obtain a copy of the License at
387
388                http://www.apache.org/licenses/LICENSE-2.0
389
390            Unless required by applicable law or agreed to in writing, software
391            distributed under the License is distributed on an "AS IS" BASIS,
392            WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
393            See the License for the specific language governing permissions and
394            limitations under the License."#
395        );
396        assert_matches_license(&license_with_appendix, OpenSourceLicense::Apache2_0);
397
398        // Sometimes people fill in the appendix with copyright info.
399        let license_with_copyright = license_with_appendix.replace(
400            "Copyright [yyyy] [name of copyright owner]",
401            "Copyright 2025 John Doe",
402        );
403        assert!(license_with_copyright != license_with_appendix);
404        assert_matches_license(&license_with_copyright, OpenSourceLicense::Apache2_0);
405
406        assert_matches_license(
407            include_str!("../../../LICENSE-APACHE"),
408            OpenSourceLicense::Apache2_0,
409        );
410
411        assert_matches_license(
412            include_str!("../license_examples/apache-2.0-ex1.txt"),
413            OpenSourceLicense::Apache2_0,
414        );
415        assert_matches_license(
416            include_str!("../license_examples/apache-2.0-ex2.txt"),
417            OpenSourceLicense::Apache2_0,
418        );
419        assert_matches_license(
420            include_str!("../license_examples/apache-2.0-ex3.txt"),
421            OpenSourceLicense::Apache2_0,
422        );
423    }
424
425    #[test]
426    fn test_apache_negative_detection() {
427        assert!(
428            detect_license(&format!(
429                "{APACHE_2_0_TXT}\n\nThe terms in this license are void if P=NP."
430            ))
431            .is_none()
432        );
433    }
434
435    #[test]
436    fn test_bsd_1_clause_positive_detection() {
437        assert_matches_license(
438            include_str!("../license_examples/bsd-1-clause.txt"),
439            OpenSourceLicense::BSD,
440        );
441    }
442
443    #[test]
444    fn test_bsd_2_clause_positive_detection() {
445        assert_matches_license(
446            include_str!("../license_examples/bsd-2-clause-ex0.txt"),
447            OpenSourceLicense::BSD,
448        );
449    }
450
451    #[test]
452    fn test_bsd_3_clause_positive_detection() {
453        assert_matches_license(
454            include_str!("../license_examples/bsd-3-clause-ex0.txt"),
455            OpenSourceLicense::BSD,
456        );
457        assert_matches_license(
458            include_str!("../license_examples/bsd-3-clause-ex1.txt"),
459            OpenSourceLicense::BSD,
460        );
461        assert_matches_license(
462            include_str!("../license_examples/bsd-3-clause-ex2.txt"),
463            OpenSourceLicense::BSD,
464        );
465        assert_matches_license(
466            include_str!("../license_examples/bsd-3-clause-ex3.txt"),
467            OpenSourceLicense::BSD,
468        );
469        assert_matches_license(
470            include_str!("../license_examples/bsd-3-clause-ex4.txt"),
471            OpenSourceLicense::BSD,
472        );
473    }
474
475    #[test]
476    fn test_bsd_0_positive_detection() {
477        assert_matches_license(BSD_0_TXT, OpenSourceLicense::BSDZero);
478    }
479
480    #[test]
481    fn test_isc_positive_detection() {
482        assert_matches_license(ISC_TXT, OpenSourceLicense::ISC);
483    }
484
485    #[test]
486    fn test_isc_negative_detection() {
487        let license_text = format!(
488            r#"{ISC_TXT}
489
490            This project is dual licensed under the ISC License and the MIT License."#
491        );
492
493        assert!(detect_license(&license_text).is_none());
494    }
495
496    #[test]
497    fn test_mit_positive_detection() {
498        assert_matches_license(MIT_TXT, OpenSourceLicense::MIT);
499        assert_matches_license(
500            include_str!("../license_examples/mit-ex1.txt"),
501            OpenSourceLicense::MIT,
502        );
503        assert_matches_license(
504            include_str!("../license_examples/mit-ex2.txt"),
505            OpenSourceLicense::MIT,
506        );
507        assert_matches_license(
508            include_str!("../license_examples/mit-ex3.txt"),
509            OpenSourceLicense::MIT,
510        );
511    }
512
513    #[test]
514    fn test_mit_negative_detection() {
515        let license_text = format!(
516            r#"{MIT_TXT}
517
518            This project is dual licensed under the MIT License and the Apache License, Version 2.0."#
519        );
520        assert!(detect_license(&license_text).is_none());
521    }
522
523    #[test]
524    fn test_upl_positive_detection() {
525        assert_matches_license(UPL_1_0_TXT, OpenSourceLicense::UPL1_0);
526    }
527
528    #[test]
529    fn test_upl_negative_detection() {
530        let license_text = format!(
531            r#"{UPL_1_0_TXT}
532
533            This project is dual licensed under the UPL License and the MIT License."#
534        );
535
536        assert!(detect_license(&license_text).is_none());
537    }
538
539    #[test]
540    fn test_zlib_positive_detection() {
541        assert_matches_license(
542            include_str!("../license_examples/zlib-ex0.txt"),
543            OpenSourceLicense::Zlib,
544        );
545    }
546
547    #[test]
548    fn test_license_file_name_regex() {
549        // Test basic license file names
550        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE"));
551        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE"));
552        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license"));
553        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"licence"));
554
555        // Test with extensions
556        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.txt"));
557        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.md"));
558        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.txt"));
559        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.md"));
560
561        // Test with specific license types
562        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-APACHE"));
563        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-MIT"));
564        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.MIT"));
565        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE_MIT"));
566        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-ISC"));
567        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-UPL"));
568
569        // Test with "license" coming after
570        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"APACHE-LICENSE"));
571
572        // Test version numbers
573        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"APACHE-2"));
574        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"APACHE-2.0"));
575        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-1"));
576        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-2"));
577        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-3"));
578        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"BSD-3-CLAUSE"));
579
580        // Test combinations
581        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-MIT.txt"));
582        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE.ISC.md"));
583        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license_upl"));
584        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.APACHE.2.0"));
585
586        // Test case insensitive
587        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"License"));
588        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license-mit.TXT"));
589        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"LICENCE_isc.MD"));
590
591        // Test edge cases that should match
592        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"license.mit"));
593        assert!(LICENSE_FILE_NAME_REGEX.is_match(b"licence-upl.txt"));
594
595        // Test non-matching patterns
596        assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"COPYING"));
597        assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.html"));
598        assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"MYLICENSE"));
599        assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"src/LICENSE"));
600        assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE.old"));
601        assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSE-GPL"));
602        assert!(!LICENSE_FILE_NAME_REGEX.is_match(b"LICENSEABC"));
603    }
604
605    #[test]
606    fn test_canonicalize_license_text() {
607        let input = "  Paragraph 1\nwith multiple lines\n\n\n\nParagraph 2\nwith more lines\n  ";
608        let expected = "paragraph 1 with multiple lines paragraph 2 with more lines";
609        assert_eq!(canonicalize_license_text(input), expected);
610
611        // Test tabs and mixed whitespace
612        let input = "Word1\t\tWord2\n\n   Word3\r\n\r\n\r\nWord4   ";
613        let expected = "word1 word2 word3 word4";
614        assert_eq!(canonicalize_license_text(input), expected);
615    }
616
617    #[test]
618    fn test_license_detection_canonicalizes_whitespace() {
619        let mit_with_weird_spacing = unindent(
620            r#"
621                MIT License
622
623
624                Copyright (c) 2024 John Doe
625
626
627                Permission is hereby granted, free of charge, to any person obtaining a copy
628                of this software   and   associated   documentation files (the "Software"), to deal
629                in the Software without restriction, including without limitation the rights
630                to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
631                copies of the Software, and to permit persons to whom the Software is
632                furnished to do so, subject to the following conditions:
633
634
635
636                The above copyright notice and this permission notice shall be included in all
637                copies or substantial portions of the Software.
638
639
640
641                THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
642                IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
643                FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
644                AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
645                LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
646                OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
647                SOFTWARE.
648            "#
649            .trim(),
650        );
651
652        assert_matches_license(&mit_with_weird_spacing, OpenSourceLicense::MIT);
653    }
654
655    fn init_test(cx: &mut TestAppContext) {
656        cx.update(|cx| {
657            let settings_store = SettingsStore::test(cx);
658            cx.set_global(settings_store);
659            WorktreeSettings::register(cx);
660        });
661    }
662
663    #[gpui::test]
664    async fn test_watcher_single_file(cx: &mut TestAppContext) {
665        init_test(cx);
666
667        let fs = FakeFs::new(cx.background_executor.clone());
668        fs.insert_tree("/root", json!({ "main.rs": "fn main() {}" }))
669            .await;
670
671        let worktree = Worktree::local(
672            Path::new("/root/main.rs"),
673            true,
674            fs.clone(),
675            Default::default(),
676            &mut cx.to_async(),
677        )
678        .await
679        .unwrap();
680
681        let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx));
682        assert!(matches!(watcher, LicenseDetectionWatcher::SingleFile));
683        assert!(!watcher.is_project_open_source());
684    }
685
686    #[gpui::test]
687    async fn test_watcher_updates_on_changes(cx: &mut TestAppContext) {
688        init_test(cx);
689
690        let fs = FakeFs::new(cx.background_executor.clone());
691        fs.insert_tree("/root", json!({ "main.rs": "fn main() {}" }))
692            .await;
693
694        let worktree = Worktree::local(
695            Path::new("/root"),
696            true,
697            fs.clone(),
698            Default::default(),
699            &mut cx.to_async(),
700        )
701        .await
702        .unwrap();
703
704        let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx));
705        assert!(matches!(watcher, LicenseDetectionWatcher::Local { .. }));
706        assert!(!watcher.is_project_open_source());
707
708        fs.write(Path::new("/root/LICENSE-MIT"), MIT_TXT.as_bytes())
709            .await
710            .unwrap();
711
712        cx.background_executor.run_until_parked();
713        assert!(watcher.is_project_open_source());
714
715        fs.write(Path::new("/root/LICENSE-APACHE"), APACHE_2_0_TXT.as_bytes())
716            .await
717            .unwrap();
718
719        cx.background_executor.run_until_parked();
720        assert!(watcher.is_project_open_source());
721
722        fs.write(Path::new("/root/LICENSE-MIT"), "Nevermind".as_bytes())
723            .await
724            .unwrap();
725
726        // Still considered open source as LICENSE-APACHE is present
727        cx.background_executor.run_until_parked();
728        assert!(watcher.is_project_open_source());
729
730        fs.write(
731            Path::new("/root/LICENSE-APACHE"),
732            "Also nevermind".as_bytes(),
733        )
734        .await
735        .unwrap();
736
737        cx.background_executor.run_until_parked();
738        assert!(!watcher.is_project_open_source());
739    }
740
741    #[gpui::test]
742    async fn test_watcher_initially_opensource_and_then_deleted(cx: &mut TestAppContext) {
743        init_test(cx);
744
745        let fs = FakeFs::new(cx.background_executor.clone());
746        fs.insert_tree(
747            "/root",
748            json!({ "main.rs": "fn main() {}", "LICENSE-MIT": MIT_TXT }),
749        )
750        .await;
751
752        let worktree = Worktree::local(
753            Path::new("/root"),
754            true,
755            fs.clone(),
756            Default::default(),
757            &mut cx.to_async(),
758        )
759        .await
760        .unwrap();
761
762        let watcher = cx.update(|cx| LicenseDetectionWatcher::new(&worktree, cx));
763        assert!(matches!(watcher, LicenseDetectionWatcher::Local { .. }));
764
765        cx.background_executor.run_until_parked();
766        assert!(watcher.is_project_open_source());
767
768        fs.remove_file(
769            Path::new("/root/LICENSE-MIT"),
770            fs::RemoveOptions {
771                recursive: false,
772                ignore_if_not_exists: false,
773            },
774        )
775        .await
776        .unwrap();
777
778        cx.background_executor.run_until_parked();
779        assert!(!watcher.is_project_open_source());
780    }
781}