Skip to main content

linuxutils_misc/
whereis.rs

1use linuxutils_common::man::ManContent;
2
3pub const MAN: ManContent = ManContent::empty();
4
5use clap::{CommandFactory, FromArgMatches};
6use std::{
7    collections::BTreeSet,
8    fs,
9    os::unix::fs::PermissionsExt,
10    path::{Path, PathBuf},
11    process::ExitCode,
12};
13
14#[derive(Debug, Default)]
15pub struct Args {
16    pub search_binaries: bool,
17    pub search_manuals: bool,
18    pub search_sources: bool,
19    pub unusual_only: bool,
20    pub list_paths: bool,
21    pub glob_mode: bool,
22    pub custom_bin_dirs: Vec<PathBuf>,
23    pub custom_man_dirs: Vec<PathBuf>,
24    pub custom_src_dirs: Vec<PathBuf>,
25    pub names: Vec<String>,
26}
27
28#[derive(clap::Parser)]
29#[command(
30    name = "whereis",
31    about = "Locate the binary, source, and manual page files for a command"
32)]
33struct ClapArgs {
34    #[arg(short = 'b', help = "Search for binaries only")]
35    binaries: bool,
36    #[arg(short = 'm', help = "Search for manuals only")]
37    manuals: bool,
38    #[arg(short = 's', help = "Search for sources only")]
39    sources: bool,
40    #[arg(short = 'u', help = "Only show unusual entries")]
41    unusual: bool,
42    #[arg(short = 'l', help = "List effective lookup paths")]
43    list: bool,
44    #[arg(short = 'g', help = "Interpret names as glob patterns")]
45    glob: bool,
46}
47
48impl Args {
49    pub fn parse_from(raw: &[String]) -> Self {
50        let mut args = Args::default();
51
52        let mut i = 0;
53        while i < raw.len() {
54            match raw[i].as_str() {
55                "-b" => args.search_binaries = true,
56                "-m" => args.search_manuals = true,
57                "-s" => args.search_sources = true,
58                "-u" => args.unusual_only = true,
59                "-l" => args.list_paths = true,
60                "-g" => args.glob_mode = true,
61                "-B" => {
62                    i += 1;
63                    while i < raw.len() && raw[i] != "-f" {
64                        args.custom_bin_dirs.push(PathBuf::from(&raw[i]));
65                        i += 1;
66                    }
67                    if i >= raw.len() || raw[i] != "-f" {
68                        eprintln!(
69                            "whereis: -B requires -f to terminate the directory list"
70                        );
71                        std::process::exit(1);
72                    }
73                }
74                "-M" => {
75                    i += 1;
76                    while i < raw.len() && raw[i] != "-f" {
77                        args.custom_man_dirs.push(PathBuf::from(&raw[i]));
78                        i += 1;
79                    }
80                    if i >= raw.len() || raw[i] != "-f" {
81                        eprintln!(
82                            "whereis: -M requires -f to terminate the directory list"
83                        );
84                        std::process::exit(1);
85                    }
86                }
87                "-S" => {
88                    i += 1;
89                    while i < raw.len() && raw[i] != "-f" {
90                        args.custom_src_dirs.push(PathBuf::from(&raw[i]));
91                        i += 1;
92                    }
93                    if i >= raw.len() || raw[i] != "-f" {
94                        eprintln!(
95                            "whereis: -S requires -f to terminate the directory list"
96                        );
97                        std::process::exit(1);
98                    }
99                }
100                "-h" | "--help" => {
101                    let _ = ClapArgs::command().print_help();
102                    println!();
103                    std::process::exit(0);
104                }
105                "-V" | "--version" => {
106                    let cmd = ClapArgs::command();
107                    println!(
108                        "{} {}",
109                        cmd.get_name(),
110                        cmd.get_version().unwrap_or("")
111                    );
112                    std::process::exit(0);
113                }
114                other if other.starts_with('-') && other.len() > 1 => {
115                    let chars: Vec<char> = other[1..].chars().collect();
116                    let mut j = 0;
117                    while j < chars.len() {
118                        match chars[j] {
119                            'b' => args.search_binaries = true,
120                            'm' => args.search_manuals = true,
121                            's' => args.search_sources = true,
122                            'u' => args.unusual_only = true,
123                            'l' => args.list_paths = true,
124                            'g' => args.glob_mode = true,
125                            c => {
126                                eprintln!("whereis: invalid option -- '{c}'");
127                                std::process::exit(1);
128                            }
129                        }
130                        j += 1;
131                    }
132                }
133                name => {
134                    args.names.push(name.to_string());
135                }
136            }
137            i += 1;
138        }
139
140        args
141    }
142
143    pub fn command() -> clap::Command {
144        ClapArgs::command()
145    }
146
147    pub fn from_arg_matches(m: &clap::ArgMatches) -> Result<Self, clap::Error> {
148        let clap_args = ClapArgs::from_arg_matches(m)?;
149        Ok(Args {
150            search_binaries: clap_args.binaries,
151            search_manuals: clap_args.manuals,
152            search_sources: clap_args.sources,
153            unusual_only: clap_args.unusual,
154            list_paths: clap_args.list,
155            glob_mode: clap_args.glob,
156            custom_bin_dirs: Vec::new(),
157            custom_man_dirs: Vec::new(),
158            custom_src_dirs: Vec::new(),
159            names: Vec::new(),
160        })
161    }
162}
163
164fn default_bin_dirs() -> Vec<PathBuf> {
165    let mut dirs: Vec<PathBuf> = [
166        "/usr/bin",
167        "/usr/sbin",
168        "/bin",
169        "/sbin",
170        "/usr/local/bin",
171        "/usr/local/sbin",
172        "/usr/games",
173        "/usr/local/games",
174    ]
175    .iter()
176    .map(PathBuf::from)
177    .collect();
178
179    if let Ok(path) = std::env::var("PATH") {
180        for p in path.split(':') {
181            if !p.is_empty() {
182                let pb = PathBuf::from(p);
183                if !dirs.contains(&pb) {
184                    dirs.push(pb);
185                }
186            }
187        }
188    }
189
190    dirs.into_iter().filter(|d| d.is_dir()).collect()
191}
192
193fn default_man_dirs() -> Vec<PathBuf> {
194    let base_patterns = [
195        "/usr/share/man/man",
196        "/usr/local/share/man/man",
197        "/usr/local/man/man",
198    ];
199    let sections = ["1", "2", "3", "4", "5", "6", "7", "8", "1p", "3p"];
200
201    let mut dirs = Vec::new();
202    for base in &base_patterns {
203        for section in &sections {
204            let dir = PathBuf::from(format!("{base}{section}"));
205            if dir.is_dir() {
206                dirs.push(dir);
207            }
208        }
209    }
210
211    if let Ok(manpath) = std::env::var("MANPATH") {
212        for p in manpath.split(':') {
213            if !p.is_empty() {
214                let base = PathBuf::from(p);
215                if base.is_dir() {
216                    for section in &sections {
217                        let dir = base.join(format!("man{section}"));
218                        if dir.is_dir() && !dirs.contains(&dir) {
219                            dirs.push(dir);
220                        }
221                    }
222                }
223            }
224        }
225    }
226
227    dirs
228}
229
230fn default_src_dirs() -> Vec<PathBuf> {
231    let mut dirs = Vec::new();
232    for base in &["/usr/src", "/usr/local/src"] {
233        let base_path = Path::new(base);
234        if base_path.is_dir()
235            && let Ok(entries) = fs::read_dir(base_path)
236        {
237            for entry in entries.flatten() {
238                let path = entry.path();
239                if path.is_dir() {
240                    dirs.push(path);
241                }
242            }
243        }
244    }
245    dirs
246}
247
248fn is_executable(path: &Path) -> bool {
249    path.metadata()
250        .map(|m| m.permissions().mode() & 0o111 != 0 && m.is_file())
251        .unwrap_or(false)
252}
253
254fn strip_name(name: &str) -> &str {
255    let name = name.rsplit('/').next().unwrap_or(name);
256    name.strip_prefix("s.").unwrap_or(name)
257}
258
259fn matches_name(filename: &str, name: &str, glob_mode: bool) -> bool {
260    if glob_mode {
261        glob_match(name, filename)
262    } else {
263        filename == name
264    }
265}
266
267fn matches_name_with_ext(filename: &str, name: &str, glob_mode: bool) -> bool {
268    if glob_mode {
269        let base = filename.split('.').next().unwrap_or(filename);
270        glob_match(name, base)
271    } else {
272        filename == name || filename.starts_with(&format!("{name}."))
273    }
274}
275
276fn glob_match(pattern: &str, text: &str) -> bool {
277    let pat: Vec<char> = pattern.chars().collect();
278    let txt: Vec<char> = text.chars().collect();
279    glob_match_inner(&pat, &txt, 0, 0)
280}
281
282fn glob_match_inner(pat: &[char], txt: &[char], pi: usize, ti: usize) -> bool {
283    if pi == pat.len() {
284        return ti == txt.len();
285    }
286    if pat[pi] == '*' {
287        for t in ti..=txt.len() {
288            if glob_match_inner(pat, txt, pi + 1, t) {
289                return true;
290            }
291        }
292        return false;
293    }
294    if pat[pi] == '?' {
295        if ti < txt.len() {
296            return glob_match_inner(pat, txt, pi + 1, ti + 1);
297        }
298        return false;
299    }
300    if ti < txt.len() && pat[pi] == txt[ti] {
301        return glob_match_inner(pat, txt, pi + 1, ti + 1);
302    }
303    false
304}
305
306fn find_binaries(
307    name: &str,
308    dirs: &[PathBuf],
309    glob_mode: bool,
310) -> Vec<PathBuf> {
311    let mut results = BTreeSet::new();
312    for dir in dirs {
313        if let Ok(entries) = fs::read_dir(dir) {
314            for entry in entries.flatten() {
315                let path = entry.path();
316                if let Some(fname) = path.file_name().and_then(|f| f.to_str())
317                    && matches_name(fname, name, glob_mode)
318                    && is_executable(&path)
319                {
320                    results.insert(path);
321                }
322            }
323        }
324    }
325    results.into_iter().collect()
326}
327
328fn find_manuals(name: &str, dirs: &[PathBuf], glob_mode: bool) -> Vec<PathBuf> {
329    find_files_with_ext(name, dirs, glob_mode)
330}
331
332fn find_sources(name: &str, dirs: &[PathBuf], glob_mode: bool) -> Vec<PathBuf> {
333    find_files_with_ext(name, dirs, glob_mode)
334}
335
336fn find_files_with_ext(
337    name: &str,
338    dirs: &[PathBuf],
339    glob_mode: bool,
340) -> Vec<PathBuf> {
341    let mut results = BTreeSet::new();
342    for dir in dirs {
343        if let Ok(entries) = fs::read_dir(dir) {
344            for entry in entries.flatten() {
345                let path = entry.path();
346                if let Some(fname) = path.file_name().and_then(|f| f.to_str())
347                    && matches_name_with_ext(fname, name, glob_mode)
348                    && path.is_file()
349                {
350                    results.insert(path);
351                }
352            }
353        }
354    }
355    results.into_iter().collect()
356}
357
358pub fn run(args: Args) -> ExitCode {
359    let search_all =
360        !args.search_binaries && !args.search_manuals && !args.search_sources;
361    let do_bins = search_all || args.search_binaries;
362    let do_mans = search_all || args.search_manuals;
363    let do_srcs = search_all || args.search_sources;
364
365    let bin_dirs = if !args.custom_bin_dirs.is_empty() {
366        args.custom_bin_dirs.clone()
367    } else {
368        default_bin_dirs()
369    };
370
371    let man_dirs = if !args.custom_man_dirs.is_empty() {
372        args.custom_man_dirs.clone()
373    } else {
374        default_man_dirs()
375    };
376
377    let src_dirs = if !args.custom_src_dirs.is_empty() {
378        args.custom_src_dirs.clone()
379    } else {
380        default_src_dirs()
381    };
382
383    if args.list_paths {
384        if do_bins {
385            println!("bin: {}", format_dir_list(&bin_dirs));
386        }
387        if do_mans {
388            println!("man: {}", format_dir_list(&man_dirs));
389        }
390        if do_srcs {
391            println!("src: {}", format_dir_list(&src_dirs));
392        }
393        return ExitCode::SUCCESS;
394    }
395
396    if args.names.is_empty() {
397        eprintln!("whereis: no name specified");
398        return ExitCode::FAILURE;
399    }
400
401    for name in &args.names {
402        let name = strip_name(name);
403        let mut paths: Vec<PathBuf> = Vec::new();
404
405        let bin_results = if do_bins {
406            find_binaries(name, &bin_dirs, args.glob_mode)
407        } else {
408            Vec::new()
409        };
410        let man_results = if do_mans {
411            find_manuals(name, &man_dirs, args.glob_mode)
412        } else {
413            Vec::new()
414        };
415        let src_results = if do_srcs {
416            find_sources(name, &src_dirs, args.glob_mode)
417        } else {
418            Vec::new()
419        };
420
421        if args.unusual_only {
422            let mut expected_types = 0;
423            let mut found_single = 0;
424            if do_bins {
425                expected_types += 1;
426                if bin_results.len() == 1 {
427                    found_single += 1;
428                }
429            }
430            if do_mans {
431                expected_types += 1;
432                if man_results.len() == 1 {
433                    found_single += 1;
434                }
435            }
436            if do_srcs {
437                expected_types += 1;
438                if src_results.len() == 1 {
439                    found_single += 1;
440                }
441            }
442            if found_single == expected_types {
443                continue;
444            }
445        }
446
447        paths.extend(bin_results);
448        paths.extend(man_results);
449        paths.extend(src_results);
450
451        let path_strs: Vec<String> = paths
452            .iter()
453            .map(|p| p.to_string_lossy().to_string())
454            .collect();
455
456        if path_strs.is_empty() {
457            println!("{name}:");
458        } else {
459            println!("{name}: {}", path_strs.join(" "));
460        }
461    }
462
463    ExitCode::SUCCESS
464}
465
466fn format_dir_list(dirs: &[PathBuf]) -> String {
467    dirs.iter()
468        .map(|d| d.to_string_lossy().to_string())
469        .collect::<Vec<_>>()
470        .join(" ")
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_strip_name_simple() {
479        assert_eq!(strip_name("ls"), "ls");
480    }
481
482    #[test]
483    fn test_strip_name_with_path() {
484        assert_eq!(strip_name("/usr/bin/ls"), "ls");
485    }
486
487    #[test]
488    fn test_strip_name_sccs_prefix() {
489        assert_eq!(strip_name("s.main.c"), "main.c");
490    }
491
492    #[test]
493    fn test_glob_match_exact() {
494        assert!(glob_match("ls", "ls"));
495        assert!(!glob_match("ls", "lsblk"));
496    }
497
498    #[test]
499    fn test_glob_match_star() {
500        assert!(glob_match("ls*", "ls"));
501        assert!(glob_match("ls*", "lsblk"));
502        assert!(!glob_match("ls*", "cat"));
503    }
504
505    #[test]
506    fn test_glob_match_question() {
507        assert!(glob_match("l?", "ls"));
508        assert!(!glob_match("l?", "lsblk"));
509    }
510
511    #[test]
512    fn test_matches_name_with_ext_exact() {
513        assert!(matches_name_with_ext("ls.1", "ls", false));
514        assert!(matches_name_with_ext("ls.1.gz", "ls", false));
515        assert!(!matches_name_with_ext("lsblk.1", "ls", false));
516    }
517
518    #[test]
519    fn test_default_bin_dirs_not_empty() {
520        let dirs = default_bin_dirs();
521        assert!(!dirs.is_empty());
522    }
523}