Skip to main content

typg_core/
discovery.rs

1/// Filesystem font discovery.
2///
3/// Walks directory trees to find font files (TTF, OTF, TTC, OTC).
4/// Inaccessible directories are skipped with a warning to stderr.
5///
6/// Made by FontLab https://www.fontlab.com/
7use std::path::{Path, PathBuf};
8
9use anyhow::{anyhow, Result};
10use walkdir::WalkDir;
11
12/// Reference to a discovered font file on disk.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct TypgFontSourceRef {
15    /// Path to the font file.
16    pub path: PathBuf,
17}
18
19/// Trait for font discovery backends.
20pub trait FontDiscovery {
21    /// Return all font files found by this backend.
22    fn discover(&self) -> Result<Vec<TypgFontSourceRef>>;
23}
24
25/// Discovers fonts by walking filesystem paths.
26///
27/// Recurses into directories, optionally follows symlinks. Recognizes
28/// font files by extension: `.ttf`, `.otf`, `.ttc`, `.otc`.
29#[derive(Debug, Clone)]
30pub struct PathDiscovery {
31    /// Root directories to walk.
32    roots: Vec<PathBuf>,
33    /// Whether to follow symlinks during traversal.
34    follow_symlinks: bool,
35}
36
37impl PathDiscovery {
38    /// Create a new discovery for the given root paths.
39    pub fn new<I, P>(roots: I) -> Self
40    where
41        I: IntoIterator<Item = P>,
42        P: Into<PathBuf>,
43    {
44        let roots = roots.into_iter().map(Into::into).collect();
45        Self {
46            roots,
47            follow_symlinks: false,
48        }
49    }
50
51    /// Enable or disable symlink following during traversal.
52    pub fn follow_symlinks(mut self, follow: bool) -> Self {
53        self.follow_symlinks = follow;
54        self
55    }
56}
57
58impl FontDiscovery for PathDiscovery {
59    /// Walk all root paths and return discovered font files.
60    ///
61    /// Directories that can't be read (permission denied, broken symlinks, etc.)
62    /// are skipped with a warning to stderr. The walk continues.
63    fn discover(&self) -> Result<Vec<TypgFontSourceRef>> {
64        let mut found = Vec::new();
65
66        for root in &self.roots {
67            if !root.exists() {
68                return Err(anyhow!("path does not exist: {}", root.display()));
69            }
70
71            for entry in WalkDir::new(root).follow_links(self.follow_symlinks) {
72                let entry = match entry {
73                    Ok(e) => e,
74                    Err(e) => {
75                        eprintln!("warning: {e}");
76                        continue;
77                    }
78                };
79                if entry.file_type().is_file() && is_font(entry.path()) {
80                    found.push(TypgFontSourceRef {
81                        path: entry.path().to_path_buf(),
82                    });
83                }
84            }
85        }
86
87        Ok(found)
88    }
89}
90
91/// Check whether a path has a recognized font file extension.
92fn is_font(path: &Path) -> bool {
93    let ext = match path.extension().and_then(|e| e.to_str()) {
94        Some(ext) => ext.to_ascii_lowercase(),
95        None => return false,
96    };
97
98    matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc")
99}
100
101#[cfg(test)]
102mod tests {
103    use super::is_font;
104    use super::FontDiscovery;
105    use super::PathDiscovery;
106    use std::fs;
107    use tempfile::tempdir;
108
109    #[test]
110    fn recognises_font_extensions() {
111        assert!(is_font("/A/B/font.ttf".as_ref()));
112        assert!(is_font("/A/B/font.OTF".as_ref()));
113        assert!(!is_font("/A/B/font.txt".as_ref()));
114        assert!(!is_font("/A/B/font".as_ref()));
115    }
116
117    #[test]
118    fn discovers_nested_fonts() {
119        let tmp = tempdir().expect("tempdir");
120        let nested = tmp.path().join("a/b");
121        fs::create_dir_all(&nested).expect("mkdir");
122        let font_path = nested.join("sample.ttf");
123        fs::write(&font_path, b"").expect("touch font");
124
125        let discovery = PathDiscovery::new([tmp.path()]);
126        let fonts = discovery.discover().expect("discover");
127
128        assert!(fonts.iter().any(|f| f.path == font_path));
129    }
130
131    #[cfg(unix)]
132    #[test]
133    fn follows_symlinks_when_enabled() {
134        use std::os::unix::fs::symlink;
135
136        let tmp = tempdir().expect("tempdir");
137        let real_dir = tmp.path().join("real");
138        let link_dir = tmp.path().join("link");
139        fs::create_dir_all(&real_dir).expect("mkdir real");
140        let font_path = real_dir.join("linked.otf");
141        fs::write(&font_path, b"").expect("touch font");
142        symlink(&real_dir, &link_dir).expect("symlink");
143
144        let discovery = PathDiscovery::new([&link_dir]).follow_symlinks(true);
145        let fonts = discovery.discover().expect("discover");
146
147        assert!(fonts.iter().any(|f| f.path.ends_with("linked.otf")));
148    }
149}