Skip to main content

assay/
discovery.rs

1//! Module discovery and search index builder.
2//!
3//! Discovers Assay modules from three sources (in priority order):
4//! 1. Project — `./modules/` relative to CWD
5//! 2. Global  — `$ASSAY_MODULES_PATH` or `~/.assay/modules/`
6//! 3. BuiltIn — embedded stdlib + hardcoded Rust builtins
7
8use crate::search::{SearchEngine, SearchResult};
9use include_dir::{Dir, include_dir};
10
11use crate::metadata::{self, ModuleMetadata};
12#[cfg(not(feature = "db"))]
13use crate::search::BM25Index;
14#[cfg(feature = "db")]
15use crate::search_fts5::FTS5Index;
16
17static STDLIB_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/stdlib");
18
19/// Where a discovered module originates from.
20#[derive(Debug, Clone, PartialEq)]
21pub enum ModuleSource {
22    /// Embedded in the binary via `include_dir!`
23    BuiltIn,
24    /// Found in `./modules/` relative to CWD
25    Project,
26    /// Found in `$ASSAY_MODULES_PATH` or `~/.assay/modules/`
27    Global,
28}
29
30impl ModuleSource {
31    pub fn label(&self) -> &'static str {
32        match self {
33            ModuleSource::BuiltIn => "builtin",
34            ModuleSource::Project => "project",
35            ModuleSource::Global => "global",
36        }
37    }
38}
39
40/// A module discovered during the discovery phase.
41#[derive(Debug, Clone)]
42#[non_exhaustive]
43pub struct DiscoveredModule {
44    pub module_name: String,
45    pub source: ModuleSource,
46    pub metadata: ModuleMetadata,
47    pub lua_source: String,
48}
49
50/// Hardcoded Rust builtins with their descriptions and search keywords.
51const BUILTINS: &[(&str, &str, &[&str], &str)] = &[
52    (
53        "http",
54        "HTTP client and server: get, post, put, patch, delete, serve",
55        &[
56            "http", "client", "server", "request", "response", "headers", "endpoint", "api",
57            "webhook", "rest",
58        ],
59        "core",
60    ),
61    (
62        "json",
63        "JSON serialization: parse and encode",
64        &[
65            "json",
66            "serialization",
67            "deserialize",
68            "stringify",
69            "parse",
70            "encode",
71            "format",
72        ],
73        "core",
74    ),
75    (
76        "yaml",
77        "YAML serialization: parse and encode",
78        &[
79            "yaml",
80            "serialization",
81            "deserialize",
82            "parse",
83            "encode",
84            "format",
85        ],
86        "core",
87    ),
88    (
89        "toml",
90        "TOML serialization: parse and encode",
91        &[
92            "toml",
93            "serialization",
94            "deserialize",
95            "parse",
96            "encode",
97            "configuration",
98        ],
99        "core",
100    ),
101    (
102        "fs",
103        "Filesystem: read and write files",
104        &["fs", "filesystem", "file", "read", "write", "io", "path"],
105        "core",
106    ),
107    (
108        "crypto",
109        "Cryptography: jwt_sign, hash, hmac, random",
110        &[
111            "crypto",
112            "jwt",
113            "signature",
114            "hash",
115            "hmac",
116            "encryption",
117            "random",
118            "security",
119            "password",
120            "signing",
121            "rsa",
122            "sha256",
123        ],
124        "core",
125    ),
126    (
127        "base64",
128        "Base64 encoding and decoding",
129        &["base64", "encoding", "decode", "encode", "binary"],
130        "core",
131    ),
132    (
133        "regex",
134        "Regular expressions: match, find, find_all, replace",
135        &[
136            "regex",
137            "pattern",
138            "match",
139            "find",
140            "replace",
141            "regular-expression",
142            "regexp",
143        ],
144        "core",
145    ),
146    (
147        "db",
148        "Database: connect, query, execute, close (Postgres, MySQL, SQLite)",
149        &[
150            "db",
151            "database",
152            "sql",
153            "postgres",
154            "mysql",
155            "sqlite",
156            "connection",
157            "query",
158            "execute",
159        ],
160        "data",
161    ),
162    (
163        "ws",
164        "WebSocket: connect, send, recv, close",
165        &[
166            "ws",
167            "websocket",
168            "connection",
169            "message",
170            "streaming",
171            "realtime",
172            "socket",
173        ],
174        "core",
175    ),
176    (
177        "dns",
178        "DNS lookups: A, AAAA, CNAME, MX, NS, TXT, plus DNSBL blacklist checks",
179        &[
180            "dns",
181            "resolve",
182            "resolver",
183            "lookup",
184            "nameserver",
185            "mx",
186            "txt",
187            "spf",
188            "dkim",
189            "dmarc",
190            "dnsbl",
191            "blacklist",
192            "blocklist",
193            "domain",
194            "deliverability",
195        ],
196        "core",
197    ),
198    (
199        "template",
200        "Jinja2-compatible templates: render file or string",
201        &[
202            "template",
203            "jinja2",
204            "rendering",
205            "string-template",
206            "mustache",
207            "render",
208        ],
209        "core",
210    ),
211    (
212        "async",
213        "Async tasks: spawn, spawn_interval, await, cancel",
214        &[
215            "async",
216            "asynchronous",
217            "task",
218            "coroutine",
219            "concurrent",
220            "spawn",
221            "interval",
222        ],
223        "core",
224    ),
225    (
226        "assert",
227        "Assertions: eq, gt, lt, contains, not_nil, matches",
228        &[
229            "assert",
230            "assertion",
231            "test",
232            "validation",
233            "comparison",
234            "check",
235            "verify",
236        ],
237        "core",
238    ),
239    (
240        "log",
241        "Logging: info, warn, error",
242        &[
243            "log", "logging", "output", "debug", "error", "warning", "info", "trace",
244        ],
245        "core",
246    ),
247    (
248        "env",
249        "Environment variables: get",
250        &["env", "environment", "variable", "configuration", "config"],
251        "core",
252    ),
253    (
254        "sleep",
255        "Sleep for N seconds",
256        &["sleep", "delay", "pause", "wait", "time"],
257        "core",
258    ),
259    (
260        "time",
261        "Unix timestamp in seconds",
262        &["time", "timestamp", "unix", "epoch", "clock", "datetime"],
263        "core",
264    ),
265    (
266        "compress",
267        "Decompression: gunzip, unxz, unzstd. Pure binary in/out.",
268        &[
269            "compress",
270            "decompress",
271            "gunzip",
272            "gzip",
273            "xz",
274            "lzma",
275            "zstd",
276        ],
277        "core",
278    ),
279];
280
281/// Discover all modules: embedded stdlib + `./modules/` + `~/.assay/modules/` (or `$ASSAY_MODULES_PATH`).
282///
283/// Returns modules ordered by priority: Project first, then Global, then BuiltIn.
284/// Callers can deduplicate by name, keeping the highest-priority (first) occurrence.
285pub fn discover_modules() -> Vec<DiscoveredModule> {
286    let mut modules = Vec::new();
287
288    // Priority 1: Project modules (./modules/)
289    discover_filesystem_modules(
290        std::path::Path::new("./modules"),
291        ModuleSource::Project,
292        &mut modules,
293    );
294
295    // Priority 2: Global modules ($ASSAY_MODULES_PATH or ~/.assay/modules/)
296    let global_path = resolve_global_modules_path();
297    if let Some(path) = global_path {
298        discover_filesystem_modules(&path, ModuleSource::Global, &mut modules);
299    }
300
301    // Priority 3: Embedded stdlib .lua files
302    discover_embedded_stdlib(&mut modules);
303
304    // Priority 3 (continued): Hardcoded Rust builtins
305    discover_rust_builtins(&mut modules);
306
307    modules
308}
309
310/// Build a search index from discovered modules.
311///
312/// When feature `db` is enabled: uses `FTS5Index`.
313/// When feature `db` is disabled: uses `BM25Index`.
314pub fn build_index(modules: &[DiscoveredModule]) -> Box<dyn SearchEngine> {
315    #[cfg(feature = "db")]
316    {
317        let mut idx = FTS5Index::new();
318        for m in modules {
319            idx.add_document(
320                &m.module_name,
321                &[
322                    ("keywords", &m.metadata.keywords.join(" "), 3.0),
323                    ("module_name", &m.module_name, 2.0),
324                    ("description", &m.metadata.description, 1.0),
325                    ("functions", &m.metadata.auto_functions.join(" "), 1.0),
326                ],
327            );
328        }
329        Box::new(idx)
330    }
331    #[cfg(not(feature = "db"))]
332    {
333        let mut idx = BM25Index::new();
334        for m in modules {
335            idx.add_document(
336                &m.module_name,
337                &[
338                    ("keywords", &m.metadata.keywords.join(" "), 3.0),
339                    ("module_name", &m.module_name, 2.0),
340                    ("description", &m.metadata.description, 1.0),
341                    ("functions", &m.metadata.auto_functions.join(" "), 1.0),
342                ],
343            );
344        }
345        Box::new(idx)
346    }
347}
348
349/// Convenience: discover all modules, build index, search, return results.
350pub fn search_modules(query: &str, limit: usize) -> Vec<SearchResult> {
351    let modules = discover_modules();
352    let index = build_index(&modules);
353    index.search(query, limit)
354}
355
356/// Resolve the global modules directory path.
357///
358/// Checks `$ASSAY_MODULES_PATH` first, then falls back to `~/.assay/modules/`.
359/// Returns `None` if neither is available.
360fn resolve_global_modules_path() -> Option<std::path::PathBuf> {
361    if let Ok(custom) = std::env::var(crate::lua::MODULES_PATH_ENV) {
362        return Some(std::path::PathBuf::from(custom));
363    }
364    if let Ok(home) = std::env::var("HOME") {
365        return Some(std::path::Path::new(&home).join(".assay/modules"));
366    }
367    None
368}
369
370/// Discover `.lua` files from a filesystem directory.
371///
372/// Silently skips if the directory does not exist.
373fn discover_filesystem_modules(
374    dir: &std::path::Path,
375    source: ModuleSource,
376    modules: &mut Vec<DiscoveredModule>,
377) {
378    let entries = match std::fs::read_dir(dir) {
379        Ok(entries) => entries,
380        Err(_) => return, // Directory doesn't exist or can't be read — skip silently
381    };
382
383    for entry in entries.flatten() {
384        let path = entry.path();
385        if path.extension().and_then(|e| e.to_str()) != Some("lua") {
386            continue;
387        }
388
389        let lua_source = match std::fs::read_to_string(&path) {
390            Ok(s) => s,
391            Err(_) => continue,
392        };
393
394        let stem = path
395            .file_stem()
396            .and_then(|s| s.to_str())
397            .unwrap_or_default();
398        let module_name = format!("assay.{stem}");
399        let meta = metadata::parse_metadata(&lua_source);
400
401        modules.push(DiscoveredModule {
402            module_name,
403            source: source.clone(),
404            metadata: meta,
405            lua_source,
406        });
407    }
408}
409
410/// Discover embedded stdlib `.lua` files from `include_dir!`.
411///
412/// Recurses into subdirectories so nested namespaces like
413/// `engine/vault.lua` register as `assay.engine.vault`. The path-to-
414/// module-name mapping replaces the OS separator with `.`. Both
415/// `engine.lua` (the facade) and `engine/vault.lua` (a submodule) get
416/// registered as separate `assay.engine` and `assay.engine.vault`
417/// entries respectively, matching what `require()` already resolves.
418fn discover_embedded_stdlib(modules: &mut Vec<DiscoveredModule>) {
419    fn walk(dir: &include_dir::Dir<'_>, modules: &mut Vec<DiscoveredModule>) {
420        for file in dir.files() {
421            let path = file.path();
422            if path.extension().and_then(|e| e.to_str()) != Some("lua") {
423                continue;
424            }
425            let Some(lua_source) = file.contents_utf8() else {
426                continue;
427            };
428            // Build dotted path from "stdlib"-relative components,
429            // dropping the trailing `.lua`.
430            let segments: Vec<&str> = path.iter().filter_map(|c| c.to_str()).collect();
431            if segments.is_empty() {
432                continue;
433            }
434            let mut joined = segments.join(".");
435            if joined.ends_with(".lua") {
436                joined.truncate(joined.len() - 4);
437            }
438            let module_name = format!("assay.{joined}");
439            let meta = metadata::parse_metadata(lua_source);
440            modules.push(DiscoveredModule {
441                module_name,
442                source: ModuleSource::BuiltIn,
443                metadata: meta,
444                lua_source: lua_source.to_string(),
445            });
446        }
447        for sub in dir.dirs() {
448            walk(sub, modules);
449        }
450    }
451    walk(&STDLIB_DIR, modules);
452}
453
454/// Add hardcoded Rust builtins (not Lua files) to the module list.
455fn discover_rust_builtins(modules: &mut Vec<DiscoveredModule>) {
456    for &(name, description, kw, category) in BUILTINS {
457        modules.push(DiscoveredModule {
458            module_name: name.to_string(),
459            source: ModuleSource::BuiltIn,
460            lua_source: String::new(),
461            metadata: ModuleMetadata {
462                module_name: name.to_string(),
463                description: description.to_string(),
464                keywords: kw.iter().map(|k| k.to_string()).collect(),
465                category: Some(category.to_string()),
466                ..Default::default()
467            },
468        });
469    }
470}