1use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use ready_set_sdk::manifest::Manifest;
11
12const PREFIX: &str = "ready-set-";
13
14#[derive(Debug, Clone)]
16pub struct PluginEntry {
17 pub name: String,
19 pub binary_path: PathBuf,
21 pub manifest: Option<Manifest>,
23}
24
25#[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#[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 drop(list_all());
167 }
168}