asimov_cli/
subcommands_provider.rs

1// This is free and unencumbered software released into the public domain.
2
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, PartialEq, Eq, Clone)]
6pub struct Subcommand {
7    pub name: String,
8    pub path: PathBuf,
9}
10
11#[derive(Debug, Clone)]
12pub struct SubcommandsProvider {
13    commands: Vec<Subcommand>,
14}
15
16impl SubcommandsProvider {
17    pub fn collect(prefix: &str, level: usize) -> SubcommandsProvider {
18        let commands = Self::collect_commands(prefix)
19            .into_iter()
20            // Construct ExternalCommand.
21            .flat_map(|path| {
22                let name = path
23                    .file_stem()?
24                    .to_string_lossy()
25                    .trim_start_matches(prefix)
26                    .to_string();
27
28                Some(Subcommand { name, path })
29            })
30            // Respect level.
31            .filter(|cmd| {
32                let count = cmd.name.chars().filter(|&c| c == '-').count();
33                count < level
34            })
35            .collect();
36
37        SubcommandsProvider { commands }
38    }
39
40    pub fn find(prefix: &str, name: &str) -> Option<Subcommand> {
41        let name = format!("{}{}", prefix, name);
42        let path = Self::resolve_command(prefix, &name);
43        path.map(|path| Subcommand { name, path })
44    }
45}
46
47impl SubcommandsProvider {
48    pub fn iter(&self) -> impl Iterator<Item = &Subcommand> {
49        self.commands.iter()
50    }
51}
52
53#[cfg(unix)]
54impl SubcommandsProvider {
55    fn filter_file(prefix: &str, path: &Path) -> bool {
56        use std::os::unix::prelude::*;
57
58        let file_name = path.file_name();
59        let Some(entry_name) = file_name.and_then(|name| name.to_str()) else {
60            // skip files with invalid names.
61            return false;
62        };
63
64        if entry_name.starts_with(".") || entry_name.ends_with("~") {
65            // skip hidden and backup files.
66            return false;
67        }
68
69        if !entry_name.starts_with(prefix) {
70            // skip non-matching files.
71            return false;
72        }
73
74        let Ok(metadata) = std::fs::metadata(path) else {
75            // couldn't get metadata.
76            return false;
77        };
78
79        if !metadata.is_file() || metadata.permissions().mode() & 0o111 == 0 {
80            // skip non-executable files.
81            return false;
82        }
83
84        true
85    }
86
87    fn collect_commands(prefix: &str) -> Vec<PathBuf> {
88        let Some(paths) = std::env::var_os("PATH") else {
89            // PATH variable is not set.
90            return vec![];
91        };
92
93        let mut result = vec![];
94        for path in std::env::split_paths(&paths) {
95            let Ok(dir) = std::fs::read_dir(path) else {
96                continue;
97            };
98
99            for entry in dir {
100                let Ok(entry) = entry else {
101                    // invalid entry.
102                    continue;
103                };
104
105                let path = entry.path();
106                if Self::filter_file(prefix, &path) {
107                    result.push(path);
108                }
109            }
110        }
111
112        result
113    }
114
115    fn resolve_command(prefix: &str, command: &str) -> Option<PathBuf> {
116        let Some(paths) = std::env::var_os("PATH") else {
117            // PATH variable is not set.
118            return None;
119        };
120
121        for path in std::env::split_paths(&paths) {
122            let path = path.join(command);
123
124            if !path.exists() {
125                continue;
126            }
127
128            if !Self::filter_file(prefix, &path) {
129                continue;
130            }
131
132            return Some(path);
133        }
134
135        None
136    }
137}
138
139#[cfg(windows)]
140impl SubcommandsProvider {
141    fn get_path_exts() -> Option<Vec<String>> {
142        let Ok(exts) = std::env::var("PATHEXT") else {
143            // PATHEXT variable is not set.
144            return None;
145        };
146
147        // NOTE: I am not sure if std::env::split_paths should be applied here,
148        // since it also deals with '"' which seems to not be used in PATHEXT?
149        return Some(
150            exts.split(';')
151                .map(|ext| ext[1..].to_lowercase())
152                .collect::<Vec<_>>(),
153        );
154    }
155
156    fn filter_file(prefix: &str, path: &Path, exts: Option<&[String]>) -> bool {
157        use std::os::windows::fs::MetadataExt;
158        const FILE_ATTRIBUTE_HIDDEN: u32 = 0x00000002;
159
160        let file_name = path.file_name();
161        let Some(entry_name) = file_name.and_then(|name| name.to_str()) else {
162            // skip files with invalid names.
163            return false;
164        };
165
166        if !entry_name.starts_with(prefix) {
167            // skip non-matching files.
168            return false;
169        }
170
171        if let Some(exts) = exts {
172            let Some(entry_ext) = path.extension().and_then(|ext| ext.to_str()) else {
173                // skip files without extensions.
174                return false;
175            };
176
177            let entry_ext = entry_ext.to_lowercase();
178            if !exts.contains(&entry_ext) {
179                // skip non-executable files
180                return false;
181            }
182        }
183
184        let Ok(metadata) = std::fs::metadata(path) else {
185            // couldn't get metadata.
186            return false;
187        };
188
189        let is_hidden = metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0;
190        if is_hidden {
191            // skip hidden files
192            return false;
193        }
194
195        true
196    }
197
198    fn collect_commands(prefix: &str) -> Vec<PathBuf> {
199        let Some(paths) = std::env::var_os("PATH") else {
200            // PATH variable is not set.
201            return vec![];
202        };
203
204        let Some(exts) = Self::get_path_exts() else {
205            // PATHEXT variable is not set or invalid.
206            return vec![];
207        };
208
209        let mut result = vec![];
210        for path in std::env::split_paths(&paths) {
211            let Ok(dir) = std::fs::read_dir(path) else {
212                continue;
213            };
214
215            for entry in dir {
216                let Ok(entry) = entry else {
217                    // invalid entry.
218                    continue;
219                };
220
221                let path = entry.path();
222                if Self::filter_file(prefix, &path, Some(&exts)) {
223                    result.push(path);
224                }
225            }
226        }
227
228        result
229    }
230
231    fn resolve_command(prefix: &str, command: &str) -> Option<PathBuf> {
232        let Some(paths) = std::env::var_os("PATH") else {
233            // PATH variable is not set.
234            return None;
235        };
236
237        let Some(exts) = Self::get_path_exts() else {
238            // PATHEXT variable is not set or invalid.
239            return None;
240        };
241
242        for path in std::env::split_paths(&paths) {
243            let mut path = path.join(command);
244
245            // Extension is provided. Just check if file exists.
246            if path.extension().is_some() {
247                match path.exists() {
248                    true if Self::filter_file(prefix, &path, None) => return Some(path),
249                    _ => continue,
250                }
251            }
252
253            // Iterate extensions and check if file exists.
254            for ext in &exts {
255                path.set_extension(ext);
256
257                match path.exists() {
258                    true if Self::filter_file(prefix, &path, None) => return Some(path),
259                    _ => continue,
260                }
261            }
262        }
263
264        None
265    }
266}