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}