Skip to main content

typg_core/
discovery.rs

1/// Filesystem font discovery — the first step in every search.
2///
3/// Before you can query font metadata, you need to know where the fonts live.
4/// This module walks directory trees, identifies font files by extension, and
5/// hands back a list of paths for the search engine to open.
6///
7/// Recognized extensions: `.ttf` (TrueType), `.otf` (OpenType/CFF),
8/// `.ttc` (TrueType Collection), `.otc` (OpenType Collection).
9/// WOFF/WOFF2 web fonts are not included — they're compressed containers
10/// meant for browsers, not typically installed on the system.
11///
12/// Directories that can't be read (permissions, broken mounts, dangling
13/// symlinks) are silently skipped. The walk continues. A single locked
14/// folder shouldn't kill a search across thousands of fonts.
15///
16/// Made by FontLab <https://www.fontlab.com/>
17use std::path::{Path, PathBuf};
18
19use anyhow::{anyhow, Result};
20use walkdir::WalkDir;
21
22/// A font file found on disk during discovery.
23///
24/// At this stage we only know *where* the file is, not what's inside it.
25/// Metadata extraction happens later in the [`search`](crate::search) module.
26/// A TTC/OTC collection file appears as a single `TypgFontSourceRef` here;
27/// the search module will enumerate individual faces within it.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct TypgFontSourceRef {
30    /// Absolute (or as-given) path to the font file on disk.
31    pub path: PathBuf,
32}
33
34/// Trait for font discovery backends.
35///
36/// The default implementation ([`PathDiscovery`]) walks the local filesystem.
37/// Alternative backends could scan network shares, font servers, or package
38/// managers — anything that can produce a list of font file paths.
39pub trait FontDiscovery {
40    /// Scan for font files and return their locations.
41    ///
42    /// Implementations should be resilient: skip inaccessible paths rather
43    /// than aborting the entire scan. Return `Err` only for truly fatal
44    /// problems (e.g., the root path itself doesn't exist).
45    fn discover(&self) -> Result<Vec<TypgFontSourceRef>>;
46}
47
48/// Discovers fonts by walking filesystem paths.
49///
50/// Give it one or more root directories. It recurses into every subdirectory,
51/// checks each file's extension, and collects anything that looks like a font.
52///
53/// Symlink behavior matters in practice: macOS `/System/Library/Fonts` contains
54/// symlinks into sealed system volumes, and many Linux setups symlink font
55/// directories across partitions. Enable [`follow_symlinks`](Self::follow_symlinks)
56/// when you want to reach fonts behind those links. Leave it off (the default)
57/// to avoid infinite loops from circular symlinks.
58///
59/// # Example
60///
61/// ```rust,no_run
62/// use typg_core::discovery::{PathDiscovery, FontDiscovery};
63///
64/// let fonts = PathDiscovery::new(["/usr/share/fonts", "/home/me/.fonts"])
65///     .follow_symlinks(true)
66///     .discover()?;
67///
68/// println!("Found {} font files", fonts.len());
69/// # Ok::<(), anyhow::Error>(())
70/// ```
71#[derive(Debug, Clone)]
72pub struct PathDiscovery {
73    /// Root directories to walk. Each is traversed recursively.
74    roots: Vec<PathBuf>,
75    /// Follow symbolic links during traversal. Off by default to prevent
76    /// infinite loops from circular symlinks.
77    follow_symlinks: bool,
78}
79
80impl PathDiscovery {
81    /// Create a discovery instance for the given root paths.
82    ///
83    /// Each path should be a directory. If you pass a file path, `walkdir`
84    /// will yield just that one file (which is fine for single-file checks).
85    pub fn new<I, P>(roots: I) -> Self
86    where
87        I: IntoIterator<Item = P>,
88        P: Into<PathBuf>,
89    {
90        let roots = roots.into_iter().map(Into::into).collect();
91        Self {
92            roots,
93            follow_symlinks: false,
94        }
95    }
96
97    /// Enable or disable symlink following during traversal.
98    pub fn follow_symlinks(mut self, follow: bool) -> Self {
99        self.follow_symlinks = follow;
100        self
101    }
102}
103
104impl FontDiscovery for PathDiscovery {
105    /// Walk all root paths and return every font file found.
106    ///
107    /// The walk is resilient: directories that can't be read (permission
108    /// denied, broken symlinks, vanished network mounts) are silently
109    /// skipped. One unreadable folder won't abort a scan of thousands.
110    ///
111    /// Returns `Err` only if a root path itself doesn't exist — that's
112    /// likely a typo, and the caller should know about it.
113    fn discover(&self) -> Result<Vec<TypgFontSourceRef>> {
114        let mut found = Vec::new();
115
116        for root in &self.roots {
117            if !root.exists() {
118                return Err(anyhow!("path does not exist: {}", root.display()));
119            }
120
121            for entry in WalkDir::new(root).follow_links(self.follow_symlinks) {
122                let entry = match entry {
123                    Ok(e) => e,
124                    Err(_) => {
125                        continue;
126                    }
127                };
128                if entry.file_type().is_file() && is_font(entry.path()) {
129                    found.push(TypgFontSourceRef {
130                        path: entry.path().to_path_buf(),
131                    });
132                }
133            }
134        }
135
136        Ok(found)
137    }
138}
139
140/// Check whether a file has a recognized font extension.
141///
142/// Recognized: `.ttf` (TrueType), `.otf` (OpenType/CFF), `.ttc` and `.otc`
143/// (collection files that bundle multiple faces in one file).
144/// Case-insensitive — `ARIAL.TTF` and `arial.ttf` both match.
145///
146/// Not recognized: `.woff`, `.woff2` (web font containers), `.dfont`
147/// (legacy macOS resource-fork format), `.fon` (Windows bitmap fonts).
148fn is_font(path: &Path) -> bool {
149    let ext = match path.extension().and_then(|e| e.to_str()) {
150        Some(ext) => ext.to_ascii_lowercase(),
151        None => return false,
152    };
153
154    matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc")
155}
156
157#[cfg(test)]
158mod tests {
159    use super::is_font;
160    use super::FontDiscovery;
161    use super::PathDiscovery;
162    use std::fs;
163    use tempfile::tempdir;
164
165    #[test]
166    fn recognises_font_extensions() {
167        assert!(is_font("/A/B/font.ttf".as_ref()));
168        assert!(is_font("/A/B/font.OTF".as_ref()));
169        assert!(!is_font("/A/B/font.txt".as_ref()));
170        assert!(!is_font("/A/B/font".as_ref()));
171    }
172
173    #[test]
174    fn discovers_nested_fonts() {
175        let tmp = tempdir().expect("tempdir");
176        let nested = tmp.path().join("a/b");
177        fs::create_dir_all(&nested).expect("mkdir");
178        let font_path = nested.join("sample.ttf");
179        fs::write(&font_path, b"").expect("touch font");
180
181        let discovery = PathDiscovery::new([tmp.path()]);
182        let fonts = discovery.discover().expect("discover");
183
184        assert!(fonts.iter().any(|f| f.path == font_path));
185    }
186
187    #[cfg(unix)]
188    #[test]
189    fn follows_symlinks_when_enabled() {
190        use std::os::unix::fs::symlink;
191
192        let tmp = tempdir().expect("tempdir");
193        let real_dir = tmp.path().join("real");
194        let link_dir = tmp.path().join("link");
195        fs::create_dir_all(&real_dir).expect("mkdir real");
196        let font_path = real_dir.join("linked.otf");
197        fs::write(&font_path, b"").expect("touch font");
198        symlink(&real_dir, &link_dir).expect("symlink");
199
200        let discovery = PathDiscovery::new([&link_dir]).follow_symlinks(true);
201        let fonts = discovery.discover().expect("discover");
202
203        assert!(fonts.iter().any(|f| f.path.ends_with("linked.otf")));
204    }
205}