terminfo_lean/
locate.rs

1// Copyright 2025 Pavel Roskin
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9//! Search for terminfo database file for the terminal
10
11use std::{
12    env,
13    ffi::OsStr,
14    path::{Path, PathBuf},
15};
16
17const TERMINFO_DIRS: &[&str] = &[
18    "/etc/terminfo",
19    "/lib/terminfo",
20    "/usr/share/terminfo",
21    "/usr/lib/terminfo",
22    "/boot/system/data/terminfo", // haiku
23];
24
25/// Errors reported when looking for a terminfo database file
26#[derive(thiserror::Error, Debug, PartialEq)]
27#[non_exhaustive]
28pub enum Error {
29    /// The name of the terminal is not valid
30    #[error("InvalidTerminalName")]
31    InvalidTerminalName,
32    /// Terminfo file for the terminal could not be found
33    #[error("File not found")]
34    FileNotFound,
35}
36
37fn find_in_directory(term_name: &OsStr, dir: &Path) -> Result<PathBuf, Error> {
38    let Some(first_byte) = term_name.as_encoded_bytes().first() else {
39        return Err(Error::InvalidTerminalName);
40    };
41
42    // Standard layout - leaf directories use the first character of the terminal name.
43    let first_char = *first_byte as char;
44    let filename = dir.join(first_char.to_string()).join(term_name);
45    if filename.exists() {
46        return Ok(filename);
47    }
48
49    // Layout for systems with non-case-sensitive filesystems (MacOS, Windows) - leaf
50    // directories use the first byte of the terminal name in hexadecimal form.
51    let first_byte_hex = format!("{:02x}", *first_byte);
52    let filename = dir.join(first_byte_hex).join(term_name);
53    if filename.exists() {
54        return Ok(filename);
55    }
56
57    Err(Error::FileNotFound)
58}
59
60/// Find all directories to be searched for terminfo files
61///
62/// This function does not attempt to verify if the directories to be searched actually exist.
63///
64/// Returns a vector of directories.
65pub fn search_directories() -> Vec<PathBuf> {
66    let mut search_dirs = vec![];
67
68    // Lazily evaluated iterator, consumed at most once.
69    let mut default_dirs = TERMINFO_DIRS.iter().map(PathBuf::from);
70
71    // Search the directory from the `TERMINFO` environment variable.
72    if let Ok(dir) = env::var("TERMINFO") {
73        search_dirs.push(PathBuf::from(&dir));
74    }
75
76    // Search `.terminfo` in the home directory.
77    if let Some(home_dir) = env::home_dir() {
78        let dir = home_dir.join(".terminfo");
79        search_dirs.push(dir);
80    }
81
82    // Search colon separated directories from the `TERMINFO_DIRS`
83    // environment variable.
84    if let Ok(dirs) = env::var("TERMINFO_DIRS") {
85        for dir in dirs.split(':') {
86            if dir.is_empty() {
87                // Empty directory means search the default locations.
88                search_dirs.extend(&mut default_dirs);
89            } else {
90                search_dirs.push(PathBuf::from(dir));
91            }
92        }
93    }
94
95    // Search default terminfo locations (nothing is added if used already).
96    search_dirs.extend(&mut default_dirs);
97
98    search_dirs
99}
100
101/// Find terminfo database file for the terminal name
102///
103/// # Arguments
104///
105/// * `term_name` - terminal name.
106///
107/// Returns the file path if it exist, an error otherwise.
108pub fn locate(term_name: impl AsRef<OsStr>) -> Result<PathBuf, Error> {
109    for dir in search_directories() {
110        match find_in_directory(term_name.as_ref(), &dir) {
111            Ok(file) => return Ok(file),
112            Err(Error::FileNotFound) => {}
113            Err(err) => return Err(err),
114        }
115    }
116
117    Err(Error::FileNotFound)
118}
119
120#[cfg(test)]
121mod test {
122    use std::fs::{File, create_dir, exists};
123
124    use tempdir::TempDir;
125
126    use super::*;
127
128    const TERM_NAME: &str = "no-such-terminal-123";
129
130    #[test]
131    fn empty_name() {
132        assert_eq!(locate(""), Err(Error::InvalidTerminalName));
133    }
134
135    #[test]
136    fn missing_file() {
137        // Not using TERM_NAME to avoid race conditions - `temp_env::with_vars`
138        // is serialized, but we are not using that function here.
139        assert_eq!(locate("no-such-terminal-1"), Err(Error::FileNotFound));
140    }
141
142    #[test]
143    fn found_xterm() {
144        let found_file = locate("xterm");
145        assert!(found_file.is_ok());
146        assert!(exists(found_file.unwrap()).unwrap());
147    }
148
149    #[test]
150    fn found_standard_layout_terminfo_dirs() {
151        let tempdir = TempDir::new("terminfo-test").unwrap();
152        let tempdir = tempdir.path();
153        let leaf_dir = tempdir.join("n");
154        let terminfo_file = leaf_dir.join(TERM_NAME);
155        create_dir(leaf_dir).unwrap();
156        File::create(&terminfo_file).unwrap();
157        let terminfo_dirs = format!("foo:{}:bar", tempdir.display());
158
159        temp_env::with_vars(
160            [("TERMINFO_DIRS", Some(terminfo_dirs)), ("TERMINFO", None)],
161            || {
162                assert_eq!(locate(TERM_NAME), Ok(terminfo_file));
163            },
164        );
165    }
166
167    #[test]
168    fn found_hex_layout_terminfo_dirs() {
169        let tempdir = TempDir::new("terminfo-test").unwrap();
170        let tempdir = tempdir.path();
171        let leaf_dir = tempdir.join("6e");
172        let terminfo_file = leaf_dir.join(TERM_NAME);
173        create_dir(leaf_dir).unwrap();
174        File::create(&terminfo_file).unwrap();
175        let terminfo_dirs = format!("foo:{}:bar", tempdir.display());
176
177        temp_env::with_vars(
178            [("TERMINFO_DIRS", Some(terminfo_dirs)), ("TERMINFO", None)],
179            || {
180                assert_eq!(locate(TERM_NAME), Ok(terminfo_file));
181            },
182        );
183    }
184
185    #[test]
186    fn found_standard_layout_terminfo_variable() {
187        let tempdir = TempDir::new("terminfo-test").unwrap();
188        let tempdir = tempdir.path();
189        let leaf_dir = tempdir.join("n");
190        let terminfo_file = leaf_dir.join(TERM_NAME);
191        create_dir(leaf_dir).unwrap();
192        File::create(&terminfo_file).unwrap();
193
194        temp_env::with_vars(
195            [("TERMINFO_DIRS", None), ("TERMINFO", Some(tempdir))],
196            || {
197                assert_eq!(locate(TERM_NAME), Ok(terminfo_file));
198            },
199        );
200    }
201
202    #[test]
203    fn dot_terminfo_standard_layout() {
204        let tempdir = TempDir::new("terminfo-test").unwrap();
205        let tempdir = tempdir.path();
206        let dot_terminfo = tempdir.join(".terminfo");
207        let leaf_dir = dot_terminfo.join("n");
208        let terminfo_file = leaf_dir.join(TERM_NAME);
209        create_dir(dot_terminfo).unwrap();
210        create_dir(leaf_dir).unwrap();
211        File::create(&terminfo_file).unwrap();
212
213        temp_env::with_vars(
214            [
215                ("TERMINFO_DIRS", None),
216                ("TERMINFO", None),
217                ("HOME", Some(tempdir)),
218            ],
219            || {
220                assert_eq!(locate(TERM_NAME), Ok(terminfo_file));
221            },
222        );
223    }
224
225    #[test]
226    fn search_order() {
227        let expected_dirs: Vec<PathBuf> = [
228            "/my/terminfo",
229            "/home/user/.terminfo",
230            "/my/terminfo1",
231            "/my/terminfo2",
232            "/etc/terminfo",
233            "/lib/terminfo",
234            "/usr/share/terminfo",
235            "/usr/lib/terminfo",
236            "/boot/system/data/terminfo",
237        ]
238        .iter()
239        .map(PathBuf::from)
240        .collect();
241
242        temp_env::with_vars(
243            [
244                ("TERMINFO_DIRS", Some("/my/terminfo1:/my/terminfo2")),
245                ("TERMINFO", Some("/my/terminfo")),
246                ("HOME", Some("/home/user")),
247            ],
248            || {
249                assert_eq!(search_directories(), expected_dirs);
250            },
251        );
252    }
253
254    #[test]
255    fn search_order_with_empty_element() {
256        let expected_dirs: Vec<PathBuf> = [
257            "/my/terminfo",
258            "/home/user/.terminfo",
259            "/my/terminfo1",
260            "/etc/terminfo",
261            "/lib/terminfo",
262            "/usr/share/terminfo",
263            "/usr/lib/terminfo",
264            "/boot/system/data/terminfo",
265            "/my/terminfo2",
266        ]
267        .iter()
268        .map(PathBuf::from)
269        .collect();
270
271        temp_env::with_vars(
272            [
273                ("TERMINFO_DIRS", Some("/my/terminfo1::/my/terminfo2")),
274                ("TERMINFO", Some("/my/terminfo")),
275                ("HOME", Some("/home/user")),
276            ],
277            || {
278                assert_eq!(search_directories(), expected_dirs);
279            },
280        );
281    }
282}