Skip to main content

qli_ext/
discovery.rs

1//! Extension discovery: walk one or more on-disk extension roots and
2//! `PATH`, building the group/extension table the dispatcher uses to route
3//! subcommands.
4//!
5//! Discovery is pure: it returns a [`Discovery`] value plus a list of
6//! human-readable warnings. The CLI binary is responsible for printing the
7//! warnings to stderr. This keeps the library testable and lets callers
8//! decide their own logging policy.
9//!
10//! Phase 1H ranks two sources: the user's `$XDG_DATA_HOME/qli/extensions/`
11//! and the embedded-defaults cache materialized by [`crate::defaults`].
12//! [`discover`] takes the sources in priority order; the **first** source
13//! to claim a group name keeps it wholesale — its manifest *and* its
14//! extensions. Later sources' copies of that group are silently shadowed.
15//! Per-group (not per-extension) shadowing means a user who deletes
16//! `dev/hello` from XDG does not see it re-appear from embedded.
17//!
18//! `PATH` binaries are merged in last and only attach to groups that
19//! already exist in some on-disk source. They never define a new group.
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::fs;
23use std::path::{Path, PathBuf};
24use std::str::FromStr;
25
26use crate::manifest::Manifest;
27
28/// Group names we never let an extension shadow. Includes today's static
29/// `qli` subcommand (`completions`) plus the names already promised by later
30/// phases of the foundation plan (`ext`, `analyze`, `lsp`, `index`,
31/// `self-update`, `mcp`) and clap's own `help`. A user group with one of
32/// these names is skipped with a warning rather than left to panic clap at
33/// `--help` time.
34const RESERVED_GROUP_NAMES: &[&str] = &[
35    "analyze",
36    "completions",
37    "ext",
38    "help",
39    "index",
40    "lsp",
41    "mcp",
42    "self-update",
43];
44
45/// Result of walking the extensions root and PATH.
46#[derive(Debug)]
47pub struct Discovery {
48    pub groups: BTreeMap<String, Group>,
49    pub warnings: Vec<String>,
50}
51
52/// A discovered extension group: one `_manifest.toml` plus the executables
53/// rooted under it.
54#[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/// A single dispatchable extension within a group.
63#[derive(Debug)]
64pub struct Extension {
65    pub name: String,
66    pub group: String,
67    pub path: PathBuf,
68    pub origin: ExtensionOrigin,
69}
70
71/// Where an extension's executable was found.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum ExtensionOrigin {
74    /// `<xdg_root>/<group>/<name>` — user-installed, editable.
75    Xdg,
76    /// Materialized from the binary's embedded defaults to a cache root
77    /// (`$XDG_CACHE_HOME/qli/embedded/<version>/<group>/<name>`).
78    Embedded,
79    /// `qli-<group>-<name>` discovered on `PATH`.
80    Path,
81}
82
83impl ExtensionOrigin {
84    /// Canonical lowercase label. Used by both the human-facing `--help`
85    /// blurb (`xdg: /path/to/ext`) and the machine-readable `qli ext list` /
86    /// `qli ext which` output, so the two stay in sync.
87    #[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
97/// Walk every `(root, origin)` source in priority order, then merge
98/// matching `qli-<group>-<name>` binaries from `PATH`, returning every
99/// group + extension we can dispatch.
100///
101/// Sources earlier in the slice win. A group claimed by source N is
102/// silently shadowed in sources N+1, N+2, ... — including its
103/// extensions, not just its manifest. This keeps "user owns it now"
104/// semantics: if XDG defines `dev` (even if `dev/hello` is missing), the
105/// embedded `dev/hello` does not bleed through.
106///
107/// Missing roots are not an error — they're skipped. Bad manifests,
108/// non-executable files, reserved group names, malformed PATH binary
109/// names, and unknown-group PATH binaries each produce a warning and are
110/// skipped.
111pub 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
121/// Walk one source and add its groups to `groups`. Existing entries are
122/// not overwritten — earlier sources win.
123fn 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            // Earlier source already claimed this group; later sources
162            // do not contribute, even per-extension.
163            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
299/// Walk every directory in `PATH`, return `(group, extension, path)` tuples
300/// for every regular, executable file whose basename matches
301/// `qli-<group>-<extension>`. Both halves must be non-empty; extra dashes in
302/// the extension name are kept verbatim (`qli-dev-hello-world` → group
303/// `dev`, ext `hello-world`).
304fn 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            // First occurrence on PATH wins (matches shell behaviour).
343            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    // Non-Unix executable detection (PATHEXT, etc.) is deferred until a
360    // Windows port is in scope. Treat any regular file as executable so
361    // discovery doesn't silently swallow scripts on those platforms.
362    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        // No XDG root has `dev`; embedded does. Embedded fills in.
496        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        // Both sources define `dev`; XDG must win wholesale, including
520        // its (potentially different) extensions list.
521        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        // Add a second extension in embedded that XDG doesn't have. The
537        // shadowing rule means it must NOT bleed through.
538        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        // XDG defines `dev`; embedded defines `prod`. Both should appear.
562        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}