1use std::collections::{BTreeMap, BTreeSet};
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::str::FromStr;
25
26use crate::manifest::Manifest;
27
28const RESERVED_GROUP_NAMES: &[&str] = &[
35 "analyze",
36 "completions",
37 "ext",
38 "help",
39 "index",
40 "lsp",
41 "mcp",
42 "self-update",
43];
44
45#[derive(Debug)]
47pub struct Discovery {
48 pub groups: BTreeMap<String, Group>,
49 pub warnings: Vec<String>,
50}
51
52#[derive(Debug)]
55pub struct Group {
56 pub name: String,
57 pub manifest: Manifest,
58 pub manifest_path: PathBuf,
59 pub extensions: BTreeMap<String, Extension>,
60}
61
62#[derive(Debug)]
64pub struct Extension {
65 pub name: String,
66 pub group: String,
67 pub path: PathBuf,
68 pub origin: ExtensionOrigin,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum ExtensionOrigin {
74 Xdg,
76 Embedded,
79 Path,
81}
82
83impl ExtensionOrigin {
84 #[must_use]
88 pub fn as_str(self) -> &'static str {
89 match self {
90 ExtensionOrigin::Xdg => "xdg",
91 ExtensionOrigin::Embedded => "embedded",
92 ExtensionOrigin::Path => "path",
93 }
94 }
95}
96
97pub fn discover(sources: &[(&Path, ExtensionOrigin)]) -> Discovery {
112 let mut warnings = Vec::new();
113 let mut groups: BTreeMap<String, Group> = BTreeMap::new();
114 for (root, origin) in sources {
115 scan_root(root, *origin, &mut groups, &mut warnings);
116 }
117 merge_path_binaries(&mut groups, &mut warnings);
118 Discovery { groups, warnings }
119}
120
121fn scan_root(
124 root: &Path,
125 origin: ExtensionOrigin,
126 groups: &mut BTreeMap<String, Group>,
127 warnings: &mut Vec<String>,
128) {
129 let entries = match fs::read_dir(root) {
130 Ok(it) => it,
131 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
132 Err(err) => {
133 warnings.push(format!(
134 "could not read extensions root {}: {err}",
135 root.display(),
136 ));
137 return;
138 }
139 };
140
141 for entry in entries.flatten() {
142 let path = entry.path();
143 if !path.is_dir() {
144 continue;
145 }
146 let Some(name) = path.file_name().and_then(|s| s.to_str()).map(str::to_owned) else {
147 warnings.push(format!(
148 "skipping group with non-UTF-8 directory name at {}",
149 path.display(),
150 ));
151 continue;
152 };
153 if RESERVED_GROUP_NAMES.contains(&name.as_str()) {
154 warnings.push(format!(
155 "group `{name}` at {} shadows a built-in qli subcommand; skipping",
156 path.display(),
157 ));
158 continue;
159 }
160 if groups.contains_key(&name) {
161 continue;
164 }
165 let manifest_path = path.join("_manifest.toml");
166 let Some(manifest) = load_manifest(&manifest_path, warnings) else {
167 continue;
168 };
169 let extensions = scan_group_executables(&path, &name, origin, warnings);
170 groups.insert(
171 name.clone(),
172 Group {
173 name,
174 manifest,
175 manifest_path,
176 extensions,
177 },
178 );
179 }
180}
181
182fn load_manifest(manifest_path: &Path, warnings: &mut Vec<String>) -> Option<Manifest> {
183 let bytes = match fs::read_to_string(manifest_path) {
184 Ok(b) => b,
185 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
186 Err(err) => {
187 warnings.push(format!(
188 "could not read manifest {}: {err}",
189 manifest_path.display(),
190 ));
191 return None;
192 }
193 };
194 match Manifest::from_str(&bytes) {
195 Ok(m) => Some(m),
196 Err(err) => {
197 warnings.push(format!(
198 "skipping group at {}: {err}",
199 manifest_path.display(),
200 ));
201 None
202 }
203 }
204}
205
206fn scan_group_executables(
207 group_dir: &Path,
208 group_name: &str,
209 origin: ExtensionOrigin,
210 warnings: &mut Vec<String>,
211) -> BTreeMap<String, Extension> {
212 let mut extensions = BTreeMap::new();
213 let entries = match fs::read_dir(group_dir) {
214 Ok(it) => it,
215 Err(err) => {
216 warnings.push(format!(
217 "could not list group {} at {}: {err}",
218 group_name,
219 group_dir.display(),
220 ));
221 return extensions;
222 }
223 };
224
225 for entry in entries.flatten() {
226 let path = entry.path();
227 let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
228 warnings.push(format!(
229 "skipping non-UTF-8 file under {}",
230 group_dir.display(),
231 ));
232 continue;
233 };
234 if file_name.starts_with('_') {
235 continue;
236 }
237 let Ok(meta) = fs::metadata(&path) else {
238 continue;
239 };
240 if !meta.is_file() {
241 continue;
242 }
243 if !is_executable(&meta) {
244 warnings.push(format!(
245 "skipping non-executable file {}; chmod +x to enable",
246 path.display(),
247 ));
248 continue;
249 }
250 let name = file_name.to_owned();
251 extensions.insert(
252 name.clone(),
253 Extension {
254 name,
255 group: group_name.to_owned(),
256 path,
257 origin,
258 },
259 );
260 }
261 extensions
262}
263
264fn merge_path_binaries(groups: &mut BTreeMap<String, Group>, warnings: &mut Vec<String>) {
265 for (group_name, ext_name, path) in scan_path_for_qli_binaries(warnings) {
266 if RESERVED_GROUP_NAMES.contains(&group_name.as_str()) {
267 warnings.push(format!(
268 "PATH binary `qli-{group_name}-{ext_name}` ({}) uses reserved group name `{group_name}`; skipping",
269 path.display(),
270 ));
271 continue;
272 }
273 let Some(group) = groups.get_mut(&group_name) else {
274 warnings.push(format!(
275 "PATH binary `qli-{group_name}-{ext_name}` references unknown group `{group_name}`; create extensions/{group_name}/_manifest.toml to enable it",
276 ));
277 continue;
278 };
279 if let Some(existing) = group.extensions.get(&ext_name) {
280 warnings.push(format!(
281 "extension `{group_name} {ext_name}` exists in both XDG ({}) and PATH ({}); using XDG. Use `qli ext which` to inspect.",
282 existing.path.display(),
283 path.display(),
284 ));
285 continue;
286 }
287 group.extensions.insert(
288 ext_name.clone(),
289 Extension {
290 name: ext_name,
291 group: group_name,
292 path,
293 origin: ExtensionOrigin::Path,
294 },
295 );
296 }
297}
298
299fn scan_path_for_qli_binaries(warnings: &mut Vec<String>) -> Vec<(String, String, PathBuf)> {
305 let Some(path_var) = std::env::var_os("PATH") else {
306 return Vec::new();
307 };
308 let mut found = Vec::new();
309 let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
310 for dir in std::env::split_paths(&path_var) {
311 let Ok(entries) = fs::read_dir(&dir) else {
312 continue;
313 };
314 for entry in entries.flatten() {
315 let path = entry.path();
316 let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
317 continue;
318 };
319 let Some(rest) = file_name.strip_prefix("qli-") else {
320 continue;
321 };
322 let Some((group, ext)) = rest.split_once('-') else {
323 warnings.push(format!(
324 "PATH binary `{file_name}` ({}) is missing a group/extension separator; expected `qli-<group>-<name>`",
325 path.display(),
326 ));
327 continue;
328 };
329 if group.is_empty() || ext.is_empty() {
330 warnings.push(format!(
331 "PATH binary `{file_name}` ({}) has an empty group or extension name; expected `qli-<group>-<name>`",
332 path.display(),
333 ));
334 continue;
335 }
336 let Ok(meta) = fs::metadata(&path) else {
337 continue;
338 };
339 if !meta.is_file() || !is_executable(&meta) {
340 continue;
341 }
342 if seen.insert((group.to_owned(), ext.to_owned())) {
344 found.push((group.to_owned(), ext.to_owned(), path));
345 }
346 }
347 }
348 found
349}
350
351#[cfg(unix)]
352fn is_executable(meta: &fs::Metadata) -> bool {
353 use std::os::unix::fs::PermissionsExt;
354 meta.permissions().mode() & 0o111 != 0
355}
356
357#[cfg(not(unix))]
358fn is_executable(_meta: &fs::Metadata) -> bool {
359 true
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use std::fs::{self, File};
369 use std::io::Write;
370
371 #[cfg(unix)]
372 fn chmod_exec(path: &Path) {
373 use std::os::unix::fs::PermissionsExt;
374 let mut perms = fs::metadata(path).unwrap().permissions();
375 perms.set_mode(0o755);
376 fs::set_permissions(path, perms).unwrap();
377 }
378
379 fn write(path: &Path, body: &str) {
380 if let Some(parent) = path.parent() {
381 fs::create_dir_all(parent).unwrap();
382 }
383 let mut f = File::create(path).unwrap();
384 f.write_all(body.as_bytes()).unwrap();
385 }
386
387 fn write_manifest(group_dir: &Path, description: &str) {
388 write(
389 &group_dir.join("_manifest.toml"),
390 &format!("schema_version = 1\ndescription = \"{description}\"\n"),
391 );
392 }
393
394 fn xdg(root: &Path) -> [(&Path, ExtensionOrigin); 1] {
395 [(root, ExtensionOrigin::Xdg)]
396 }
397
398 #[test]
399 fn missing_root_is_empty() {
400 let tmp = tempfile::tempdir().unwrap();
401 let missing = tmp.path().join("does-not-exist");
402 let d = discover(&xdg(&missing));
403 assert!(d.groups.is_empty());
404 assert!(d.warnings.is_empty());
405 }
406
407 #[test]
408 #[cfg(unix)]
409 fn discovers_group_and_executable() {
410 let tmp = tempfile::tempdir().unwrap();
411 let group_dir = tmp.path().join("dev");
412 write_manifest(&group_dir, "Dev tools");
413 let script = group_dir.join("hello");
414 write(&script, "#!/bin/sh\necho hi\n");
415 chmod_exec(&script);
416
417 let d = discover(&xdg(tmp.path()));
418 let group = d.groups.get("dev").expect("dev group present");
419 assert_eq!(group.manifest.description, "Dev tools");
420 let ext = group.extensions.get("hello").expect("hello extension");
421 assert_eq!(ext.path, script);
422 assert_eq!(ext.origin, ExtensionOrigin::Xdg);
423 assert!(d.warnings.is_empty(), "warnings: {:?}", d.warnings);
424 }
425
426 #[test]
427 #[cfg(unix)]
428 fn skips_files_starting_with_underscore() {
429 let tmp = tempfile::tempdir().unwrap();
430 let group_dir = tmp.path().join("dev");
431 write_manifest(&group_dir, "Dev tools");
432 let script = group_dir.join("_helper");
433 write(&script, "#!/bin/sh\n");
434 chmod_exec(&script);
435
436 let d = discover(&xdg(tmp.path()));
437 let group = d.groups.get("dev").unwrap();
438 assert!(group.extensions.is_empty());
439 assert!(d.warnings.is_empty());
440 }
441
442 #[test]
443 #[cfg(unix)]
444 fn warns_on_non_executable() {
445 let tmp = tempfile::tempdir().unwrap();
446 let group_dir = tmp.path().join("dev");
447 write_manifest(&group_dir, "Dev tools");
448 write(&group_dir.join("hello"), "#!/bin/sh\n");
449
450 let d = discover(&xdg(tmp.path()));
451 let group = d.groups.get("dev").unwrap();
452 assert!(group.extensions.is_empty());
453 assert_eq!(d.warnings.len(), 1, "warnings: {:?}", d.warnings);
454 assert!(d.warnings[0].contains("non-executable"));
455 assert!(d.warnings[0].contains("hello"));
456 }
457
458 #[test]
459 fn warns_and_skips_malformed_manifest() {
460 let tmp = tempfile::tempdir().unwrap();
461 let group_dir = tmp.path().join("dev");
462 write(&group_dir.join("_manifest.toml"), "schema_version = 99\n");
463
464 let d = discover(&xdg(tmp.path()));
465 assert!(d.groups.is_empty());
466 assert_eq!(d.warnings.len(), 1, "warnings: {:?}", d.warnings);
467 assert!(d.warnings[0].contains("schema_version"));
468 }
469
470 #[test]
471 fn skips_subdir_without_manifest() {
472 let tmp = tempfile::tempdir().unwrap();
473 fs::create_dir_all(tmp.path().join("dev")).unwrap();
474 let d = discover(&xdg(tmp.path()));
475 assert!(d.groups.is_empty());
476 assert!(d.warnings.is_empty());
477 }
478
479 #[test]
480 fn warns_on_reserved_group_name() {
481 let tmp = tempfile::tempdir().unwrap();
482 let group_dir = tmp.path().join("completions");
483 write_manifest(&group_dir, "Should be skipped");
484
485 let d = discover(&xdg(tmp.path()));
486 assert!(d.groups.is_empty());
487 assert_eq!(d.warnings.len(), 1, "warnings: {:?}", d.warnings);
488 assert!(d.warnings[0].contains("completions"));
489 assert!(d.warnings[0].contains("built-in"));
490 }
491
492 #[test]
493 #[cfg(unix)]
494 fn embedded_visible_when_xdg_missing_group() {
495 let tmp = tempfile::tempdir().unwrap();
497 let xdg_root = tmp.path().join("xdg");
498 let embedded_root = tmp.path().join("embedded");
499 let group_dir = embedded_root.join("dev");
500 write_manifest(&group_dir, "Embedded dev");
501 let script = group_dir.join("hello");
502 write(&script, "#!/bin/sh\necho embedded\n");
503 chmod_exec(&script);
504
505 let sources: &[(&Path, ExtensionOrigin)] = &[
506 (xdg_root.as_path(), ExtensionOrigin::Xdg),
507 (embedded_root.as_path(), ExtensionOrigin::Embedded),
508 ];
509 let d = discover(sources);
510 let group = d.groups.get("dev").expect("dev group should be visible");
511 let ext = group.extensions.get("hello").unwrap();
512 assert_eq!(ext.origin, ExtensionOrigin::Embedded);
513 assert_eq!(ext.path, script);
514 }
515
516 #[test]
517 #[cfg(unix)]
518 fn xdg_shadows_embedded_per_group() {
519 let tmp = tempfile::tempdir().unwrap();
522 let xdg_root = tmp.path().join("xdg");
523 let embedded_root = tmp.path().join("embedded");
524
525 let xdg_dev = xdg_root.join("dev");
526 write_manifest(&xdg_dev, "User-edited dev");
527 let xdg_script = xdg_dev.join("hello");
528 write(&xdg_script, "#!/bin/sh\necho user\n");
529 chmod_exec(&xdg_script);
530
531 let embedded_dev = embedded_root.join("dev");
532 write_manifest(&embedded_dev, "Embedded dev");
533 let embedded_script = embedded_dev.join("hello");
534 write(&embedded_script, "#!/bin/sh\necho embedded\n");
535 chmod_exec(&embedded_script);
536 let embedded_extra = embedded_dev.join("only-embedded");
539 write(&embedded_extra, "#!/bin/sh\necho only-embedded\n");
540 chmod_exec(&embedded_extra);
541
542 let sources: &[(&Path, ExtensionOrigin)] = &[
543 (xdg_root.as_path(), ExtensionOrigin::Xdg),
544 (embedded_root.as_path(), ExtensionOrigin::Embedded),
545 ];
546 let d = discover(sources);
547 let group = d.groups.get("dev").unwrap();
548 assert_eq!(group.manifest.description, "User-edited dev");
549 let ext = group.extensions.get("hello").unwrap();
550 assert_eq!(ext.origin, ExtensionOrigin::Xdg);
551 assert_eq!(ext.path, xdg_script);
552 assert!(
553 !group.extensions.contains_key("only-embedded"),
554 "embedded extras must not bleed into a group XDG owns",
555 );
556 }
557
558 #[test]
559 #[cfg(unix)]
560 fn distinct_groups_layer_across_sources() {
561 let tmp = tempfile::tempdir().unwrap();
563 let xdg_root = tmp.path().join("xdg");
564 let embedded_root = tmp.path().join("embedded");
565
566 let xdg_dev = xdg_root.join("dev");
567 write_manifest(&xdg_dev, "Dev");
568 let xdg_script = xdg_dev.join("hello");
569 write(&xdg_script, "#!/bin/sh\n");
570 chmod_exec(&xdg_script);
571
572 let emb_prod = embedded_root.join("prod");
573 write_manifest(&emb_prod, "Prod");
574 let emb_script = emb_prod.join("hello");
575 write(&emb_script, "#!/bin/sh\n");
576 chmod_exec(&emb_script);
577
578 let sources: &[(&Path, ExtensionOrigin)] = &[
579 (xdg_root.as_path(), ExtensionOrigin::Xdg),
580 (embedded_root.as_path(), ExtensionOrigin::Embedded),
581 ];
582 let d = discover(sources);
583 assert_eq!(
584 d.groups.get("dev").unwrap().extensions["hello"].origin,
585 ExtensionOrigin::Xdg
586 );
587 assert_eq!(
588 d.groups.get("prod").unwrap().extensions["hello"].origin,
589 ExtensionOrigin::Embedded
590 );
591 }
592}