Skip to main content

typg_python/
lib.rs

1//! Python bindings for typg.
2//!
3//! These bindings expose the same query model as the Rust CLI: live scans,
4//! in-memory filtering of cached metadata, and optional indexed search when the
5//! `hpindex` feature is enabled.
6//!
7//! Search functions return Python dictionaries shaped like:
8//! `{ "path": str, "ttc_index": int | None, "metadata": { ... } }`.
9//!
10//! Built by FontLab (https://www.fontlab.com/).
11
12use std::ops::RangeInclusive;
13use std::path::{Path, PathBuf};
14
15use anyhow::{anyhow, Result};
16use pyo3::exceptions::PyValueError;
17use pyo3::prelude::*;
18use pyo3::types::PyDict;
19use regex::Regex;
20use typg_core::query::{
21    parse_codepoint_list, parse_family_class, parse_tag_list, parse_u16_range, FamilyClassFilter,
22    Query,
23};
24use typg_core::search::{
25    filter_cached, search, SearchOptions, TypgFontFaceMatch, TypgFontFaceMeta, TypgFontSource,
26};
27use typg_core::tags::tag_to_string;
28
29#[cfg(feature = "hpindex")]
30use typg_core::index::FontIndex;
31
32/// Input structure holding font metadata provided from Python.
33///
34/// Contains all fields required for font filtering operations: file path,
35/// name strings, OpenType tag lists, supported codepoints, classification
36/// values, and variable-font status.
37#[derive(Clone, Debug, FromPyObject)]
38struct MetadataInput {
39    /// Absolute path where this font resides on disk
40    path: PathBuf,
41    /// All known names for this font family
42    #[pyo3(default)]
43    names: Vec<String>,
44    /// Variable font variation axes supported
45    #[pyo3(default)]
46    axis_tags: Vec<String>,
47    /// OpenType feature tags available for advanced typography
48    #[pyo3(default)]
49    feature_tags: Vec<String>,
50    /// Script tags indicating supported writing systems
51    #[pyo3(default)]
52    script_tags: Vec<String>,
53    /// Font table tags included in this font file
54    #[pyo3(default)]
55    table_tags: Vec<String>,
56    /// Unicode characters this font can render
57    #[pyo3(default)]
58    codepoints: Vec<String>,
59    /// Indicates whether this is a variable font
60    #[pyo3(default)]
61    is_variable: bool,
62    /// TTC collection index if font is part of a collection
63    #[pyo3(default)]
64    ttc_index: Option<u32>,
65    /// Font weight class (100-900, where 400 is regular)
66    #[pyo3(default)]
67    weight_class: Option<u16>,
68    /// Font width class (1-9, where 5 is normal)
69    #[pyo3(default)]
70    width_class: Option<u16>,
71    /// Font family class classification bits
72    #[pyo3(default)]
73    family_class: Option<u16>,
74    /// Creator-related name strings (copyright, trademark, manufacturer, etc.)
75    #[pyo3(default)]
76    creator_names: Vec<String>,
77    /// License-related name strings (copyright, license, license URL)
78    #[pyo3(default)]
79    license_names: Vec<String>,
80}
81
82/// Search directories and return matching fonts as Python dictionaries.
83///
84/// Each result has the shape `{path, ttc_index, metadata}`. `metadata`
85/// contains the extracted typg fields such as names, tags, coverage, and OS/2
86/// classification values.
87#[pyfunction]
88#[pyo3(
89    signature = (
90        paths,
91        axes=None,
92        features=None,
93        scripts=None,
94        tables=None,
95        names=None,
96        codepoints=None,
97        text=None,
98        weight=None,
99        width=None,
100        family_class=None,
101        creator=None,
102        license=None,
103        variable=false,
104        follow_symlinks=false,
105        jobs=None
106    )
107)]
108#[allow(clippy::too_many_arguments)]
109fn find_py(
110    py: Python<'_>,
111    paths: Vec<PathBuf>,
112    axes: Option<Vec<String>>,
113    features: Option<Vec<String>>,
114    scripts: Option<Vec<String>>,
115    tables: Option<Vec<String>>,
116    names: Option<Vec<String>>,
117    codepoints: Option<Vec<String>>,
118    text: Option<String>,
119    weight: Option<String>,
120    width: Option<String>,
121    family_class: Option<String>,
122    creator: Option<Vec<String>>,
123    license: Option<Vec<String>>,
124    variable: bool,
125    follow_symlinks: bool,
126    jobs: Option<usize>,
127) -> PyResult<Vec<Py<PyAny>>> {
128    if paths.is_empty() {
129        return Err(PyValueError::new_err(
130            "at least one search path is required",
131        ));
132    }
133
134    if matches!(jobs, Some(0)) {
135        return Err(PyValueError::new_err(
136            "jobs must be at least 1 when provided",
137        ));
138    }
139
140    // Build query from parameters
141    let query = build_query(
142        axes,
143        features,
144        scripts,
145        tables,
146        names,
147        codepoints,
148        text,
149        weight,
150        width,
151        family_class,
152        creator,
153        license,
154        variable,
155    )
156    .map_err(to_py_err)?;
157
158    let opts = SearchOptions {
159        follow_symlinks,
160        jobs,
161    };
162
163    let matches = search(&paths, &query, &opts).map_err(to_py_err)?;
164    to_py_matches(py, matches)
165}
166
167/// Search directories and return only matching font paths.
168///
169/// Paths from TTC or OTC collections include `#index` suffixes so the selected
170/// face is still unambiguous.
171#[pyfunction]
172#[pyo3(
173    signature = (
174        paths,
175        axes=None,
176        features=None,
177        scripts=None,
178        tables=None,
179        names=None,
180        codepoints=None,
181        text=None,
182        weight=None,
183        width=None,
184        family_class=None,
185        creator=None,
186        license=None,
187        variable=false,
188        follow_symlinks=false,
189        jobs=None
190    )
191)]
192#[allow(clippy::too_many_arguments)]
193fn find_paths_py(
194    paths: Vec<PathBuf>,
195    axes: Option<Vec<String>>,
196    features: Option<Vec<String>>,
197    scripts: Option<Vec<String>>,
198    tables: Option<Vec<String>>,
199    names: Option<Vec<String>>,
200    codepoints: Option<Vec<String>>,
201    text: Option<String>,
202    weight: Option<String>,
203    width: Option<String>,
204    family_class: Option<String>,
205    creator: Option<Vec<String>>,
206    license: Option<Vec<String>>,
207    variable: bool,
208    follow_symlinks: bool,
209    jobs: Option<usize>,
210) -> PyResult<Vec<String>> {
211    if paths.is_empty() {
212        return Err(PyValueError::new_err(
213            "at least one search path is required",
214        ));
215    }
216
217    if matches!(jobs, Some(0)) {
218        return Err(PyValueError::new_err(
219            "jobs must be at least 1 when provided",
220        ));
221    }
222
223    let query = build_query(
224        axes,
225        features,
226        scripts,
227        tables,
228        names,
229        codepoints,
230        text,
231        weight,
232        width,
233        family_class,
234        creator,
235        license,
236        variable,
237    )
238    .map_err(to_py_err)?;
239
240    let opts = SearchOptions {
241        follow_symlinks,
242        jobs,
243    };
244    let matches = search(&paths, &query, &opts).map_err(to_py_err)?;
245
246    Ok(matches
247        .into_iter()
248        .map(|m| m.source.path_with_index())
249        .collect())
250}
251
252/// Filter pre-collected font metadata without touching the filesystem.
253///
254/// `entries` must follow the same structure returned by the search functions.
255#[pyfunction]
256#[pyo3(
257    signature = (
258        entries,
259        axes=None,
260        features=None,
261        scripts=None,
262        tables=None,
263        names=None,
264        codepoints=None,
265        text=None,
266        weight=None,
267        width=None,
268        family_class=None,
269        creator=None,
270        license=None,
271        variable=false
272    )
273)]
274#[allow(clippy::too_many_arguments)]
275fn filter_cached_py(
276    py: Python<'_>,
277    entries: Vec<MetadataInput>,
278    axes: Option<Vec<String>>,
279    features: Option<Vec<String>>,
280    scripts: Option<Vec<String>>,
281    tables: Option<Vec<String>>,
282    names: Option<Vec<String>>,
283    codepoints: Option<Vec<String>>,
284    text: Option<String>,
285    weight: Option<String>,
286    width: Option<String>,
287    family_class: Option<String>,
288    creator: Option<Vec<String>>,
289    license: Option<Vec<String>>,
290    variable: bool,
291) -> PyResult<Vec<Py<PyAny>>> {
292    // Convert Python metadata input to internal Rust structures
293    let metadata = convert_metadata(entries).map_err(to_py_err)?;
294
295    let query = build_query(
296        axes,
297        features,
298        scripts,
299        tables,
300        names,
301        codepoints,
302        text,
303        weight,
304        width,
305        family_class,
306        creator,
307        license,
308        variable,
309    )
310    .map_err(to_py_err)?;
311
312    let matches = filter_cached(&metadata, &query);
313    to_py_matches(py, matches)
314}
315
316/// Search fonts through the LMDB index instead of scanning directories.
317///
318/// Requires a build compiled with the `hpindex` feature.
319#[cfg(feature = "hpindex")]
320#[pyfunction]
321#[pyo3(
322    signature = (
323        index_path,
324        axes=None,
325        features=None,
326        scripts=None,
327        tables=None,
328        names=None,
329        codepoints=None,
330        text=None,
331        weight=None,
332        width=None,
333        family_class=None,
334        creator=None,
335        license=None,
336        variable=false
337    )
338)]
339#[allow(clippy::too_many_arguments)]
340fn find_indexed_py(
341    py: Python<'_>,
342    index_path: PathBuf,
343    axes: Option<Vec<String>>,
344    features: Option<Vec<String>>,
345    scripts: Option<Vec<String>>,
346    tables: Option<Vec<String>>,
347    names: Option<Vec<String>>,
348    codepoints: Option<Vec<String>>,
349    text: Option<String>,
350    weight: Option<String>,
351    width: Option<String>,
352    family_class: Option<String>,
353    creator: Option<Vec<String>>,
354    license: Option<Vec<String>>,
355    variable: bool,
356) -> PyResult<Vec<Py<PyAny>>> {
357    let query = build_query(
358        axes,
359        features,
360        scripts,
361        tables,
362        names,
363        codepoints,
364        text,
365        weight,
366        width,
367        family_class,
368        creator,
369        license,
370        variable,
371    )
372    .map_err(to_py_err)?;
373
374    // Execute indexed search
375    let index = FontIndex::open(&index_path).map_err(to_py_err)?;
376    let reader = index.reader().map_err(to_py_err)?;
377    let matches = reader.find(&query).map_err(to_py_err)?;
378    to_py_matches(py, matches)
379}
380
381/// List every font currently stored in the LMDB index.
382///
383/// Requires a build compiled with the `hpindex` feature.
384#[cfg(feature = "hpindex")]
385#[pyfunction]
386fn list_indexed_py(py: Python<'_>, index_path: PathBuf) -> PyResult<Vec<Py<PyAny>>> {
387    let index = FontIndex::open(&index_path).map_err(to_py_err)?;
388    let reader = index.reader().map_err(to_py_err)?;
389    let matches = reader.list_all().map_err(to_py_err)?;
390    to_py_matches(py, matches)
391}
392
393/// Return the number of fonts currently stored in the LMDB index.
394///
395/// Requires a build compiled with the `hpindex` feature.
396#[cfg(feature = "hpindex")]
397#[pyfunction]
398fn count_indexed_py(index_path: PathBuf) -> PyResult<usize> {
399    let index = FontIndex::open(&index_path).map_err(to_py_err)?;
400    index.count().map_err(to_py_err)
401}
402
403fn convert_metadata(entries: Vec<MetadataInput>) -> Result<Vec<TypgFontFaceMatch>> {
404    entries
405        .into_iter()
406        .map(|entry| {
407            let mut names = entry.names;
408            if names.is_empty() {
409                names.push(default_name(&entry.path));
410            }
411
412            Ok(TypgFontFaceMatch {
413                source: TypgFontSource {
414                    path: entry.path,
415                    ttc_index: entry.ttc_index,
416                },
417                metadata: TypgFontFaceMeta {
418                    names,
419                    axis_tags: parse_tag_list(&entry.axis_tags)?,
420                    feature_tags: parse_tag_list(&entry.feature_tags)?,
421                    script_tags: parse_tag_list(&entry.script_tags)?,
422                    table_tags: parse_tag_list(&entry.table_tags)?,
423                    codepoints: parse_codepoints(&entry.codepoints)?,
424                    is_variable: entry.is_variable,
425                    weight_class: entry.weight_class,
426                    width_class: entry.width_class,
427                    family_class: entry
428                        .family_class
429                        .map(|raw| (((raw >> 8) & 0xFF) as u8, (raw & 0x00FF) as u8)),
430                    creator_names: entry.creator_names,
431                    license_names: entry.license_names,
432                },
433            })
434        })
435        .collect()
436}
437
438fn default_name(path: &Path) -> String {
439    path.file_stem()
440        .map(|s| s.to_string_lossy().to_string())
441        .unwrap_or_else(|| path.display().to_string())
442}
443
444#[allow(clippy::too_many_arguments)]
445fn build_query(
446    axes: Option<Vec<String>>,
447    features: Option<Vec<String>>,
448    scripts: Option<Vec<String>>,
449    tables: Option<Vec<String>>,
450    names: Option<Vec<String>>,
451    codepoints: Option<Vec<String>>,
452    text: Option<String>,
453    weight: Option<String>,
454    width: Option<String>,
455    family_class: Option<String>,
456    creator: Option<Vec<String>>,
457    license: Option<Vec<String>>,
458    variable: bool,
459) -> Result<Query> {
460    let axes = parse_tag_list(&axes.unwrap_or_default())?;
461    let features = parse_tag_list(&features.unwrap_or_default())?;
462    let scripts = parse_tag_list(&scripts.unwrap_or_default())?;
463    let tables = parse_tag_list(&tables.unwrap_or_default())?;
464    let name_patterns = compile_patterns(&names.unwrap_or_default())?;
465    let creator_patterns = compile_patterns(&creator.unwrap_or_default())?;
466    let license_patterns = compile_patterns(&license.unwrap_or_default())?;
467
468    let weight_range = parse_optional_range(weight)?;
469    let width_range = parse_optional_range(width)?;
470    let family_class = parse_optional_family_class(family_class)?;
471
472    let mut cps = parse_codepoints(&codepoints.unwrap_or_default())?;
473    if let Some(text) = text {
474        cps.extend(text.chars());
475    }
476    dedup_chars(&mut cps);
477
478    Ok(Query::new()
479        .with_axes(axes)
480        .with_features(features)
481        .with_scripts(scripts)
482        .with_tables(tables)
483        .with_name_patterns(name_patterns)
484        .with_creator_patterns(creator_patterns)
485        .with_license_patterns(license_patterns)
486        .with_codepoints(cps)
487        .require_variable(variable)
488        .with_weight_range(weight_range)
489        .with_width_range(width_range)
490        .with_family_class(family_class))
491}
492
493fn parse_codepoints(raw: &[String]) -> Result<Vec<char>> {
494    let mut cps = Vec::new();
495    for chunk in raw {
496        cps.extend(parse_codepoint_list(chunk)?);
497    }
498    Ok(cps)
499}
500
501fn parse_optional_range(raw: Option<String>) -> Result<Option<RangeInclusive<u16>>> {
502    match raw {
503        Some(value) => Ok(Some(parse_u16_range(&value)?)),
504        None => Ok(None),
505    }
506}
507
508fn parse_optional_family_class(raw: Option<String>) -> Result<Option<FamilyClassFilter>> {
509    match raw {
510        Some(value) => Ok(Some(parse_family_class(&value)?)),
511        None => Ok(None),
512    }
513}
514
515fn compile_patterns(patterns: &[String]) -> Result<Vec<Regex>> {
516    patterns
517        .iter()
518        .map(|p| Regex::new(p).map_err(|e| anyhow!("invalid regex {p}: {e}")))
519        .collect()
520}
521
522fn dedup_chars(cps: &mut Vec<char>) {
523    cps.sort();
524    cps.dedup();
525}
526
527fn to_py_matches(py: Python<'_>, matches: Vec<TypgFontFaceMatch>) -> PyResult<Vec<Py<PyAny>>> {
528    matches
529        .into_iter()
530        .map(|item| {
531            let meta = &item.metadata;
532
533            let meta_dict = PyDict::new(py);
534            meta_dict.set_item("names", meta.names.clone())?;
535            meta_dict.set_item(
536                "axis_tags",
537                meta.axis_tags
538                    .iter()
539                    .map(|t| tag_to_string(*t))
540                    .collect::<Vec<_>>(),
541            )?;
542            meta_dict.set_item(
543                "feature_tags",
544                meta.feature_tags
545                    .iter()
546                    .map(|t| tag_to_string(*t))
547                    .collect::<Vec<_>>(),
548            )?;
549            meta_dict.set_item(
550                "script_tags",
551                meta.script_tags
552                    .iter()
553                    .map(|t| tag_to_string(*t))
554                    .collect::<Vec<_>>(),
555            )?;
556            meta_dict.set_item(
557                "table_tags",
558                meta.table_tags
559                    .iter()
560                    .map(|t| tag_to_string(*t))
561                    .collect::<Vec<_>>(),
562            )?;
563            meta_dict.set_item(
564                "codepoints",
565                meta.codepoints
566                    .iter()
567                    .map(|c| c.to_string())
568                    .collect::<Vec<_>>(),
569            )?;
570            meta_dict.set_item("is_variable", meta.is_variable)?;
571            meta_dict.set_item("weight_class", meta.weight_class)?;
572            meta_dict.set_item("width_class", meta.width_class)?;
573            meta_dict.set_item("family_class", meta.family_class)?;
574            meta_dict.set_item("creator_names", meta.creator_names.clone())?;
575            meta_dict.set_item("license_names", meta.license_names.clone())?;
576
577            let outer = PyDict::new(py);
578            outer.set_item("path", item.source.path.to_string_lossy().to_string())?;
579            outer.set_item("ttc_index", item.source.ttc_index)?;
580            outer.set_item("metadata", meta_dict)?;
581
582            Ok(outer.into_any().unbind())
583        })
584        .collect()
585}
586
587fn to_py_err(err: anyhow::Error) -> PyErr {
588    PyValueError::new_err(err.to_string())
589}
590
591#[pymodule]
592#[pyo3(name = "_typg_python")]
593fn typg_python(_py: Python<'_>, m: &Bound<PyModule>) -> PyResult<()> {
594    m.add_function(wrap_pyfunction!(find_py, m)?)?;
595    m.add_function(wrap_pyfunction!(find_paths_py, m)?)?;
596    m.add_function(wrap_pyfunction!(filter_cached_py, m)?)?;
597
598    #[cfg(feature = "hpindex")]
599    {
600        m.add_function(wrap_pyfunction!(find_indexed_py, m)?)?;
601        m.add_function(wrap_pyfunction!(list_indexed_py, m)?)?;
602        m.add_function(wrap_pyfunction!(count_indexed_py, m)?)?;
603    }
604
605    Ok(())
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    fn metadata(path: &str, names: &[&str], axes: &[&str], variable: bool) -> MetadataInput {
613        MetadataInput {
614            path: PathBuf::from(path),
615            names: names.iter().map(|s| s.to_string()).collect(),
616            axis_tags: axes.iter().map(|s| s.to_string()).collect(),
617            feature_tags: Vec::new(),
618            script_tags: Vec::new(),
619            table_tags: Vec::new(),
620            codepoints: vec!["A".into()],
621            is_variable: variable,
622            ttc_index: None,
623            weight_class: None,
624            width_class: None,
625            family_class: None,
626            creator_names: Vec::new(),
627            license_names: Vec::new(),
628        }
629    }
630
631    #[test]
632    fn filter_cached_filters_axes_and_names() {
633        Python::initialize();
634        Python::attach(|py| {
635            let entries = vec![
636                metadata("VariableVF.ttf", &["Pro VF"], &["wght"], true),
637                metadata("Static.ttf", &["Static Sans"], &[], false),
638            ];
639
640            let result = filter_cached_py(
641                py,
642                entries,
643                Some(vec!["wght".into()]),
644                None,
645                None,
646                None,
647                Some(vec!["Pro".into()]),
648                None,
649                None,
650                None,
651                None,
652                None,
653                None,
654                None,
655                true,
656            );
657
658            assert!(result.is_ok(), "expected Ok from filter_cached_py");
659            let objs = result.unwrap();
660            assert_eq!(objs.len(), 1, "only variable font with axis should match");
661            let py_any: Py<PyAny> = objs[0].clone_ref(py);
662            let bound_any = py_any.bind(py);
663            let dict = bound_any.downcast::<PyDict>().unwrap();
664            assert_eq!(
665                dict.get_item("path")
666                    .expect("path lookup")
667                    .expect("path field")
668                    .extract::<String>()
669                    .unwrap(),
670                "VariableVF.ttf"
671            );
672        });
673    }
674
675    #[test]
676    fn invalid_tag_returns_error() {
677        Python::initialize();
678        Python::attach(|py| {
679            let err = filter_cached_py(
680                py,
681                vec![metadata("Bad.ttf", &["Bad"], &["wght"], true)],
682                Some(vec!["abcde".into()]),
683                None,
684                None,
685                None,
686                None,
687                None,
688                None,
689                None,
690                None,
691                None,
692                None,
693                None,
694                false,
695            )
696            .unwrap_err();
697
698            let message = format!("{err}");
699            assert!(
700                message.contains("tag") || message.contains("invalid"),
701                "error message should mention invalid tag, got: {message}"
702            );
703        });
704    }
705
706    #[test]
707    fn find_requires_paths() {
708        Python::initialize();
709        Python::attach(|py| {
710            let err = find_py(
711                py,
712                Vec::new(),
713                None,
714                None,
715                None,
716                None,
717                None,
718                None,
719                None,
720                None,
721                None,
722                None,
723                None,
724                None,
725                false,
726                false,
727                None,
728            )
729            .unwrap_err();
730
731            assert!(
732                format!("{err}").contains("path"),
733                "should mention missing paths"
734            );
735        });
736    }
737
738    #[test]
739    fn find_paths_requires_paths() {
740        Python::initialize();
741        Python::attach(|_| {
742            let err = find_paths_py(
743                Vec::new(),
744                None,
745                None,
746                None,
747                None,
748                None,
749                None,
750                None,
751                None,
752                None,
753                None,
754                None,
755                None,
756                false,
757                false,
758                None,
759            )
760            .unwrap_err();
761
762            assert!(format!("{err}").contains("path"));
763        });
764    }
765
766    #[cfg(feature = "hpindex")]
767    #[test]
768    fn indexed_search_returns_results() {
769        use read_fonts::types::Tag;
770        use std::time::SystemTime;
771        use tempfile::TempDir;
772        use typg_core::index::FontIndex;
773
774        // Keep TempDir alive for the entire test.
775        let dir = TempDir::new().unwrap();
776        let index_path = dir.path().to_path_buf();
777
778        // Create an index with a mock font entry.
779        {
780            let index = FontIndex::open(&index_path).unwrap();
781            let mut writer = index.writer().unwrap();
782            writer
783                .add_font(
784                    Path::new("/test/IndexedFont.ttf"),
785                    None,
786                    SystemTime::UNIX_EPOCH,
787                    vec!["Indexed Font".to_string()],
788                    &[Tag::new(b"wght")],
789                    &[Tag::new(b"smcp")],
790                    &[Tag::new(b"latn")],
791                    &[],
792                    &['a', 'b', 'c'],
793                    true,
794                    Some(400),
795                    Some(5),
796                    None,
797                )
798                .unwrap();
799            writer.commit().unwrap();
800        }
801
802        // Test the bindings (dir is still alive here).
803        Python::initialize();
804        Python::attach(|py| {
805            // Test count_indexed_py.
806            let count = count_indexed_py(index_path.clone()).unwrap();
807            assert_eq!(count, 1);
808
809            // Test list_indexed_py.
810            let all = list_indexed_py(py, index_path.clone()).unwrap();
811            assert_eq!(all.len(), 1);
812
813            // Test find_indexed_py with matching filter.
814            let matches = find_indexed_py(
815                py,
816                index_path.clone(),
817                Some(vec!["wght".into()]),
818                Some(vec!["smcp".into()]),
819                None,
820                None,
821                None,
822                None,
823                None,
824                None,
825                None,
826                None,
827                None,
828                None,
829                true,
830            )
831            .unwrap();
832            assert_eq!(matches.len(), 1);
833
834            // Test find_indexed_py with non-matching filter.
835            let no_matches = find_indexed_py(
836                py,
837                index_path.clone(),
838                None,
839                Some(vec!["liga".into()]), // Not in the index
840                None,
841                None,
842                None,
843                None,
844                None,
845                None,
846                None,
847                None,
848                None,
849                None,
850                false,
851            )
852            .unwrap();
853            assert_eq!(no_matches.len(), 0);
854        });
855
856        // dir is dropped here, after all tests complete.
857    }
858}