Skip to main content

ready_set/
discovery.rs

1//! PATH discovery of `ready-set-<name>` plugin binaries.
2//!
3//! On Unix the dispatcher walks `PATH` for files named `ready-set-<name>`.
4//! On Windows it honors `PATHEXT` and matches `ready-set-<name>.exe` (and
5//! other declared executable extensions).
6
7use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use ready_set_sdk::manifest::Manifest;
11
12const PREFIX: &str = "ready-set-";
13
14/// One entry on PATH that looks like a plugin.
15#[derive(Debug, Clone)]
16pub struct PluginEntry {
17    /// Plugin subcommand name (no `ready-set-` prefix and no extension).
18    pub name: String,
19    /// Absolute path to the binary.
20    pub binary_path: PathBuf,
21    /// Manifest sidecar, if present alongside the binary.
22    pub manifest: Option<Manifest>,
23}
24
25/// Find the first plugin on PATH whose name matches `name`. Returns `None`
26/// if no match exists.
27#[must_use]
28pub fn find_plugin(name: &str) -> Option<PluginEntry> {
29    if name.is_empty() {
30        return None;
31    }
32    let target_name = format!("{PREFIX}{name}");
33    for dir in path_dirs() {
34        if let Some(p) = scan_dir_for_match(&dir, &target_name) {
35            let manifest = Manifest::sibling_of(&p)
36                .canonicalize()
37                .ok()
38                .and_then(|m| Manifest::load(&m).ok());
39            return Some(PluginEntry {
40                name: name.to_string(),
41                binary_path: p,
42                manifest,
43            });
44        }
45    }
46    None
47}
48
49/// Enumerate every `ready-set-*` binary on PATH, dedup by canonical path,
50/// load sidecar manifests when present.
51#[must_use]
52pub fn list_all() -> Vec<PluginEntry> {
53    let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
54    let mut out: Vec<PluginEntry> = Vec::new();
55    for dir in path_dirs() {
56        let Ok(read) = std::fs::read_dir(&dir) else {
57            continue;
58        };
59        for entry in read.flatten() {
60            let p = entry.path();
61            let Some(file_name) = p.file_name().and_then(|s| s.to_str()) else {
62                continue;
63            };
64            let stem = strip_executable_suffix(file_name);
65            let Some(name) = stem.strip_prefix(PREFIX) else {
66                continue;
67            };
68            if name.is_empty() {
69                continue;
70            }
71            if !is_executable(&p) {
72                continue;
73            }
74            let canon = std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
75            if !seen.insert(canon.clone()) {
76                continue;
77            }
78            let manifest = Manifest::sibling_of(&p)
79                .canonicalize()
80                .ok()
81                .and_then(|m| Manifest::load(&m).ok());
82            out.push(PluginEntry {
83                name: name.to_string(),
84                binary_path: p,
85                manifest,
86            });
87        }
88    }
89    out.sort_by(|a, b| a.name.cmp(&b.name));
90    out
91}
92
93fn path_dirs() -> Vec<PathBuf> {
94    let Some(path) = std::env::var_os("PATH") else {
95        return Vec::new();
96    };
97    std::env::split_paths(&path).collect()
98}
99
100fn scan_dir_for_match(dir: &Path, target_stem: &str) -> Option<PathBuf> {
101    for ext in candidate_extensions() {
102        let candidate = if ext.is_empty() {
103            dir.join(target_stem)
104        } else {
105            dir.join(format!("{target_stem}{ext}"))
106        };
107        if candidate.is_file() && is_executable(&candidate) {
108            return Some(candidate);
109        }
110    }
111    None
112}
113
114fn candidate_extensions() -> Vec<String> {
115    if cfg!(windows) {
116        let raw = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
117        let mut out: Vec<String> = raw
118            .split(';')
119            .filter(|s| !s.is_empty())
120            .map(str::to_ascii_lowercase)
121            .collect();
122        if out.is_empty() {
123            out.push(".exe".into());
124        }
125        out
126    } else {
127        vec![String::new()]
128    }
129}
130
131fn strip_executable_suffix(file_name: &str) -> &str {
132    if cfg!(windows) {
133        let lower = file_name.to_ascii_lowercase();
134        for ext in candidate_extensions() {
135            if lower.ends_with(&ext) {
136                return &file_name[..file_name.len() - ext.len()];
137            }
138        }
139    }
140    file_name
141}
142
143#[cfg(unix)]
144fn is_executable(path: &Path) -> bool {
145    use std::os::unix::fs::PermissionsExt;
146    std::fs::metadata(path).is_ok_and(|m| m.is_file() && (m.permissions().mode() & 0o111 != 0))
147}
148
149#[cfg(windows)]
150fn is_executable(path: &Path) -> bool {
151    path.is_file()
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn empty_name_is_not_a_plugin() {
160        assert!(find_plugin("").is_none());
161    }
162
163    #[test]
164    fn list_all_does_not_panic() {
165        // Smoke test only; a fresh env may have no plugins on PATH.
166        drop(list_all());
167    }
168}