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